open-standards/jouleclaw/jouleclaw-rs/crates/jouleclaw-hydra-client/clients/python/jclaw_hydra/client.py
Transaction Science 0c14dc33c8 jouleclaw-hydra-client: a transport's joule figure carries its grade; ungraded is an estimate
The client sealed any transport-supplied joules figure as HwShunt, in
all three clients (Rust lib.rs, Go jclaw_hydra.go, Python client.py),
though TransportResponse.joules_uj carries no grade: a transport with an
estimate, or a figure of unknown origin, had it sealed as a hardware
reading.

TransportResponse now carries the grade of the meter that produced the
figure (Rust joules_provenance: Option<Provenance>, plus with_joules;
Go JoulesProvenance string; Python joules_provenance), and one rule,
graded_joules / GradedJoules, decides what is sealed in every client:
the figure at its stated grade; an ungraded figure (or, in Go/Python, a
grade that is not HwShunt/ModelBased/Estimator) as Estimator; no figure
as a zero Estimator. The conformance vectors carry no touch and are
unchanged; Go and Python still verify 9/9.

Tests (each fails before on HwShunt): Rust an_ungraded_transport_figure_
is_sealed_as_an_estimate, a_graded_transport_figure_keeps_its_grade;
Go TestUngradedJoulesAreSealedAsEstimates, TestGradedJoulesKeepTheirGrade;
Python tests/test_energy_grade.py (pytest).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-10 18:03:10 -04:00

199 lines
7.1 KiB
Python

"""HydraClient — parse + validate + dispatch + emit CallReport."""
from __future__ import annotations
import json
from dataclasses import dataclass, field
from typing import Any, Callable, Dict, List, Optional, Protocol
try:
from blake3 import blake3 as _blake3
except ImportError as _e: # pragma: no cover - import-time only
raise ImportError(
"the `blake3` package is required: pip install blake3"
) from _e
from .hydra import ApiDocumentation, Operation, parse_api
from .shacl import ShapeGraph, ValidationReport, validate as shacl_validate
class ClientError(Exception):
"""Base class for HydraClient errors."""
class OperationNotFound(ClientError):
pass
class InputInvalid(ClientError):
def __init__(self, shape: str, report: ValidationReport) -> None:
super().__init__(
f"input does not validate against shape `{shape}` "
f"({len(report.violations)} violation(s))"
)
self.shape = shape
self.report = report
GRADES = ("HwShunt", "ModelBased", "Estimator")
@dataclass
class TransportResponse:
status: int
body: Any
# The transport's figure for the dispatch's energy, if it has one.
# None: none, and the client emits a zero graded "Estimator".
joules_uj: Optional[int] = None
# The grade of the meter that produced `joules_uj`: "HwShunt" only for a
# hardware reading, "ModelBased", or "Estimator". None (or any other
# value) is sealed "Estimator": the client cannot tell how a transport
# obtained a figure, and a figure must not claim better provenance than
# how it was obtained.
joules_provenance: Optional[str] = None
def graded_joules(self) -> "tuple[int, str]":
"""The figure and grade the client seals: the transport's figure at
its stated grade, an ungraded figure as an estimate, and no figure as
a zero estimate. Matches `TransportResponse::graded_joules` (Rust)."""
if self.joules_uj is None:
return 0, "Estimator"
if self.joules_provenance in GRADES:
return self.joules_uj, self.joules_provenance
return self.joules_uj, "Estimator"
class Transport(Protocol):
def dispatch(self, method: str, operation_id: str, input_value: Any) -> TransportResponse: ...
class MockTransport:
"""Reference transport — echo-mode by default; canned responses
by operation id otherwise. Mirrors `jouleclaw_hydra_client::MockTransport`."""
def __init__(self) -> None:
self._responses: Dict[str, TransportResponse] = {}
def respond(self, op_id: str, response: TransportResponse) -> "MockTransport":
self._responses[op_id] = response
return self
def dispatch(self, _method: str, operation_id: str, input_value: Any) -> TransportResponse:
if operation_id in self._responses:
return self._responses[operation_id]
# Echo-mode — matches jouleclaw_hydra_client::MockTransport byte
# for byte (only "echo" in the body; no "op" sidecar).
return TransportResponse(
status=200,
body={"echo": input_value},
joules_uj=None,
)
@dataclass
class CallReport:
tool_id: str
status: int
input_hash: str
output_hash: str
status_matches_contract: bool
body: Any
touch: Dict[str, Any] = field(default_factory=dict)
def to_dict(self) -> Dict[str, Any]:
return {
"tool_id": self.tool_id,
"status": self.status,
"input_hash": self.input_hash,
"output_hash": self.output_hash,
"status_matches_contract": self.status_matches_contract,
"body": self.body,
"touch": self.touch,
}
class HydraClient:
"""Generic third-generation agent client. Same surface as the Rust
`HydraClient` and the Go reference client; produces byte-equal
BLAKE3 input/output hashes on the same vectors."""
def __init__(self, api: ApiDocumentation, shapes: Optional[ShapeGraph] = None) -> None:
self.api = api
self.shapes = shapes or ShapeGraph()
@classmethod
def from_json_ld(cls, source: Any, shapes: Optional[ShapeGraph] = None) -> "HydraClient":
return cls(parse_api(source), shapes)
def operations(self) -> List[Operation]:
result: List[Operation] = []
for c in self.api.supported_class:
result.extend(c.supported_operation)
return result
def find_operation(self, name: str) -> Optional[Operation]:
for op in self.operations():
if op.jc_tool_id == name or op.id == name or op.title == name:
return op
return None
def validate_input(self, op: Operation, value: Any) -> None:
if op.expects.endswith("#Nothing"):
return
shape = self.shapes.get(op.expects)
if shape is None:
return # open-world default
report = shacl_validate(self.shapes, op.expects, value)
if not report.conforms:
raise InputInvalid(op.expects, report)
def call(self, op_name: str, input_value: Any, transport: Transport) -> CallReport:
op = self.find_operation(op_name)
if op is None:
raise OperationNotFound(f"operation `{op_name}` not found")
self.validate_input(op, input_value)
response = transport.dispatch(op.method, op.id, input_value)
tool_id = op.jc_tool_id or op.id
input_hash = blake3_hex(canonical_json(input_value))
output_hash = blake3_hex(canonical_json(response.body))
status_matches = any(
s.get("code") == response.status for s in op.status_codes
)
joules_uj, provenance = response.graded_joules()
return CallReport(
tool_id=tool_id,
status=response.status,
input_hash=input_hash,
output_hash=output_hash,
status_matches_contract=status_matches,
body=response.body,
touch={
"tool_id": tool_id,
"joules_uj": joules_uj,
"energy_provenance": provenance,
},
)
# ─────────────────────────────────────────────────────────────────────
# Canonical-JSON + BLAKE3 — the bytes the three implementations agree on
# ─────────────────────────────────────────────────────────────────────
def canonical_json(value: Any) -> str:
"""Lexically-sorted, no-whitespace JSON. Matches the Rust client's
canonical form so the BLAKE3 hash is identical across implementations.
"""
return json.dumps(_sort_keys(value), separators=(",", ":"), ensure_ascii=False)
def blake3_hex(text: str) -> str:
return _blake3(text.encode("utf-8")).hexdigest()
def _sort_keys(value: Any) -> Any:
if isinstance(value, dict):
return {k: _sort_keys(value[k]) for k in sorted(value.keys())}
if isinstance(value, list):
return [_sort_keys(v) for v in value]
return value