Skip to content

Commit 67470ff

Browse files
committed
feat: classifier with Decision dataclass
classify(tool_call, manifest) -> Decision is a pure function: same input, same output, every time. ToolCall and Decision are frozen + slotted + kw-only dataclasses. The Decision carries the tool name, a canonical effects tuple (PRECEDENCE-ordered, byte-stable), the dominant class, and a deterministic rationale string. Tool not declared → ManifestError. Tool declared → Decision with the manifest's stored effects. Phase 2 doesn't refine on the tool call's arguments — manifest is the spec; argument-aware classification is a later phase if needed. ToolCall, Decision, classify added to __all__. Tests cover the happy paths, dominance, canonical ordering, byte-stable rationale, frozen immutability, and the undeclared-tool error path. Module remains pure; Manifest import is TYPE_CHECKING-only to keep the import graph clean.
1 parent 9ed313d commit 67470ff

3 files changed

Lines changed: 292 additions & 6 deletions

File tree

src/spine_lite/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
import logging
1010

11+
from spine_lite.classifier import Decision, ToolCall, classify
1112
from spine_lite.effects import PRECEDENCE, Effect, most_restrictive
1213
from spine_lite.exceptions import (
1314
ClassificationError,
@@ -24,15 +25,18 @@
2425
__all__ = [
2526
"PRECEDENCE",
2627
"ClassificationError",
28+
"Decision",
2729
"Effect",
2830
"HookError",
2931
"Manifest",
3032
"ManifestError",
3133
"Posture",
3234
"PostureError",
3335
"SpineLiteError",
36+
"ToolCall",
3437
"ToolDefinition",
3538
"__version__",
39+
"classify",
3640
"most_restrictive",
3741
"parse_manifest",
3842
]

src/spine_lite/classifier.py

Lines changed: 108 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,113 @@
1-
"""Effect classifier (Phase 2).
1+
"""Effect classifier.
22
3-
``classify(tool_call, manifest) -> Decision`` is a pure function that lands
4-
in Phase 2. It returns the set of effects implied by a tool call against a
5-
validated manifest, plus the dominating effect under
6-
:data:`spine_lite.effects.PRECEDENCE`.
3+
The :func:`classify` function maps a :class:`ToolCall` and a validated
4+
:class:`spine_lite.manifest.Manifest` to a :class:`Decision`.
75
8-
Pure module: deterministic, no I/O, no clocks, no randomness.
6+
Pure module: deterministic, no I/O, no clocks, no randomness. Identical
7+
inputs produce identical decisions every time. The decision's
8+
``rationale`` is the only string-formatted field, and it is built from
9+
fields in canonical order so two calls with the same inputs produce the
10+
same byte-for-byte rationale.
911
"""
1012

1113
from __future__ import annotations
14+
15+
from dataclasses import dataclass, field
16+
from typing import TYPE_CHECKING, Any
17+
18+
from spine_lite.effects import Effect, most_restrictive
19+
20+
if TYPE_CHECKING:
21+
from spine_lite.manifest import Manifest
22+
23+
24+
@dataclass(frozen=True, slots=True, kw_only=True)
25+
class ToolCall:
26+
"""A planned tool invocation to classify.
27+
28+
Attributes:
29+
tool: Tool name as declared in the manifest.
30+
arguments: Free-form key/value arguments. Currently informational
31+
only; future phases may use them to refine classification
32+
beyond the manifest's declared effects.
33+
"""
34+
35+
tool: str
36+
arguments: dict[str, Any] = field(default_factory=dict)
37+
38+
39+
@dataclass(frozen=True, slots=True, kw_only=True)
40+
class Decision:
41+
"""The result of classifying a :class:`ToolCall`.
42+
43+
Attributes:
44+
tool: Echoed from the input call.
45+
effects: The full set of effect classes the call can produce, as a
46+
canonically-ordered tuple (sorted by ``PRECEDENCE``). Tuple
47+
rather than frozenset so equality and serialisation are
48+
byte-stable.
49+
most_restrictive: The dominant effect under
50+
:data:`spine_lite.PRECEDENCE`. Always a member of ``effects``.
51+
rationale: Human-readable explanation of why this effect set was
52+
chosen. Format is canonical so byte-stable across runs.
53+
"""
54+
55+
tool: str
56+
effects: tuple[Effect, ...]
57+
most_restrictive: Effect
58+
rationale: str
59+
60+
61+
def classify(tool_call: ToolCall, manifest: Manifest) -> Decision:
62+
"""Classify ``tool_call`` against ``manifest``.
63+
64+
Args:
65+
tool_call: The planned invocation.
66+
manifest: A validated :class:`Manifest` declaring the tool.
67+
68+
Returns:
69+
A :class:`Decision` carrying the effect set, the dominant effect,
70+
and a deterministic rationale.
71+
72+
Raises:
73+
ManifestError: If the tool isn't declared in the manifest.
74+
75+
Examples:
76+
>>> from spine_lite import Effect, Manifest, ToolDefinition
77+
>>> manifest = Manifest(tools={
78+
... "fetch": ToolDefinition(
79+
... name="fetch",
80+
... effects=(Effect.NETWORK, Effect.READ),
81+
... ),
82+
... })
83+
>>> decision = classify(ToolCall(tool="fetch"), manifest)
84+
>>> decision.most_restrictive
85+
<Effect.NETWORK: 'network'>
86+
>>> decision.effects
87+
(<Effect.NETWORK: 'network'>, <Effect.READ: 'read'>)
88+
"""
89+
definition = manifest.get(tool_call.tool)
90+
91+
dominant = most_restrictive(definition.effects)
92+
return Decision(
93+
tool=tool_call.tool,
94+
effects=definition.effects,
95+
most_restrictive=dominant,
96+
rationale=_rationale(tool_call.tool, definition.effects, dominant),
97+
)
98+
99+
100+
def _rationale(
101+
tool: str,
102+
effects: tuple[Effect, ...],
103+
dominant: Effect,
104+
) -> str:
105+
"""Format a deterministic rationale string."""
106+
classes = ", ".join(sorted(e.value for e in effects))
107+
return (
108+
f"tool {tool!r} declares effects [{classes}]; "
109+
f"dominant under PRECEDENCE is {dominant.value!r}"
110+
)
111+
112+
113+
__all__ = ["Decision", "ToolCall", "classify"]

tests/unit/test_classifier.py

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
"""Tests for the classifier (basic / unit).
2+
3+
Property-based tests with hypothesis live in commit 5 alongside the
4+
authored fixtures. This file covers the core behaviour and the error
5+
paths.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import pytest
11+
12+
from spine_lite import (
13+
Decision,
14+
Effect,
15+
Manifest,
16+
ManifestError,
17+
Posture,
18+
ToolCall,
19+
ToolDefinition,
20+
classify,
21+
)
22+
23+
24+
def _manifest(**tools: ToolDefinition) -> Manifest:
25+
return Manifest(tools=dict(tools))
26+
27+
28+
# ---------- happy path ----------
29+
30+
31+
def test_classify_returns_declared_effects() -> None:
32+
manifest = _manifest(
33+
read_file=ToolDefinition(name="read_file", effects=(Effect.READ,)),
34+
)
35+
decision = classify(ToolCall(tool="read_file"), manifest)
36+
37+
assert decision.tool == "read_file"
38+
assert decision.effects == (Effect.READ,)
39+
assert decision.most_restrictive is Effect.READ
40+
41+
42+
def test_classify_collapses_to_dominant_effect() -> None:
43+
manifest = _manifest(
44+
fetch=ToolDefinition(
45+
name="fetch",
46+
effects=(Effect.NETWORK, Effect.READ),
47+
),
48+
)
49+
decision = classify(ToolCall(tool="fetch"), manifest)
50+
51+
assert decision.most_restrictive is Effect.NETWORK
52+
assert set(decision.effects) == {Effect.NETWORK, Effect.READ}
53+
54+
55+
def test_classify_destructive_dominates() -> None:
56+
manifest = _manifest(
57+
nuke=ToolDefinition(
58+
name="nuke",
59+
effects=(
60+
Effect.READ,
61+
Effect.WRITE,
62+
Effect.NETWORK,
63+
Effect.DESTRUCTIVE,
64+
),
65+
),
66+
)
67+
decision = classify(ToolCall(tool="nuke"), manifest)
68+
assert decision.most_restrictive is Effect.DESTRUCTIVE
69+
70+
71+
def test_classify_returns_canonical_effect_order() -> None:
72+
"""Decision.effects always uses PRECEDENCE order, not author order."""
73+
manifest = _manifest(
74+
t=ToolDefinition(
75+
name="t",
76+
effects=(Effect.READ, Effect.NETWORK, Effect.DESTRUCTIVE),
77+
),
78+
)
79+
decision = classify(ToolCall(tool="t"), manifest)
80+
assert decision.effects == (Effect.DESTRUCTIVE, Effect.NETWORK, Effect.READ)
81+
82+
83+
def test_classify_rationale_is_human_readable() -> None:
84+
manifest = _manifest(
85+
t=ToolDefinition(name="t", effects=(Effect.NETWORK, Effect.READ)),
86+
)
87+
decision = classify(ToolCall(tool="t"), manifest)
88+
89+
assert "'t'" in decision.rationale
90+
assert "network" in decision.rationale
91+
assert "read" in decision.rationale
92+
93+
94+
def test_classify_rationale_is_byte_stable() -> None:
95+
"""Same inputs produce identical rationale strings."""
96+
manifest = _manifest(
97+
t=ToolDefinition(name="t", effects=(Effect.WRITE, Effect.READ)),
98+
)
99+
a = classify(ToolCall(tool="t"), manifest).rationale
100+
b = classify(ToolCall(tool="t"), manifest).rationale
101+
assert a == b
102+
103+
104+
def test_classify_ignores_arguments_in_phase_2() -> None:
105+
"""Phase 2 classifier doesn't refine on arguments; manifest is the spec."""
106+
manifest = _manifest(
107+
t=ToolDefinition(name="t", effects=(Effect.READ,)),
108+
)
109+
a = classify(ToolCall(tool="t", arguments={}), manifest)
110+
b = classify(ToolCall(tool="t", arguments={"path": "/etc/passwd"}), manifest)
111+
assert a.effects == b.effects
112+
assert a.most_restrictive == b.most_restrictive
113+
114+
115+
def test_classify_with_posture_constrained_tool() -> None:
116+
"""Permitted_postures is stored on the definition; Phase 2 doesn't gate on it."""
117+
manifest = _manifest(
118+
write_file=ToolDefinition(
119+
name="write_file",
120+
effects=(Effect.WRITE,),
121+
permitted_postures=(Posture.INTERACTIVE, Posture.AUTONOMOUS),
122+
),
123+
)
124+
decision = classify(ToolCall(tool="write_file"), manifest)
125+
assert decision.most_restrictive is Effect.WRITE
126+
127+
128+
# ---------- error paths ----------
129+
130+
131+
def test_classify_raises_manifest_error_for_undeclared_tool() -> None:
132+
manifest = Manifest(tools={})
133+
with pytest.raises(ManifestError, match="not declared"):
134+
classify(ToolCall(tool="ghost"), manifest)
135+
136+
137+
def test_classify_undeclared_tool_carries_name_in_message() -> None:
138+
manifest = Manifest(tools={})
139+
with pytest.raises(ManifestError) as exc_info:
140+
classify(ToolCall(tool="missing_tool"), manifest)
141+
assert "missing_tool" in str(exc_info.value)
142+
143+
144+
# ---------- determinism ----------
145+
146+
147+
def test_classify_is_deterministic_within_one_call() -> None:
148+
manifest = _manifest(
149+
t=ToolDefinition(name="t", effects=(Effect.SPAWN, Effect.NETWORK)),
150+
)
151+
call = ToolCall(tool="t")
152+
decisions = [classify(call, manifest) for _ in range(10)]
153+
assert all(d == decisions[0] for d in decisions)
154+
155+
156+
def test_decision_is_frozen() -> None:
157+
decision = Decision(
158+
tool="t",
159+
effects=(Effect.READ,),
160+
most_restrictive=Effect.READ,
161+
rationale="example",
162+
)
163+
with pytest.raises(AttributeError):
164+
decision.tool = "u" # type: ignore[misc]
165+
166+
167+
def test_tool_call_is_frozen() -> None:
168+
call = ToolCall(tool="t")
169+
with pytest.raises(AttributeError):
170+
call.tool = "u" # type: ignore[misc]
171+
172+
173+
# ---------- public API ----------
174+
175+
176+
def test_decision_classify_toolcall_in_public_api() -> None:
177+
import spine_lite
178+
179+
for name in ("Decision", "ToolCall", "classify"):
180+
assert name in spine_lite.__all__

0 commit comments

Comments
 (0)