open-standards/jouleclaw/jouleclaw-rs/crates/jouleclaw-hydra-client/clients/go/jclaw_hydra.go
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

568 lines
18 KiB
Go

// Package jclawhydra is the Go reference implementation of the
// JouleClaw third-generation Hydra client.
//
// Parses any Hydra ApiDocumentation, validates a candidate input
// against the linked SHACL shape graph, dispatches via a pluggable
// transport, and returns a CallReport with BLAKE3 hashes over
// canonical-JSON of the input/output payloads.
//
// Produces byte-for-byte identical CallReport hashes against the
// Rust reference client (`jouleclaw-hydra-client`) and the Python
// reference client (`clients/python/jclaw_hydra/`). All three are
// validated against the same vectors in
// `conformance/call-reports.json`.
package jclawhydra
import (
"encoding/hex"
"encoding/json"
"fmt"
"regexp"
"sort"
"strings"
"lukechampine.com/blake3"
)
// JCNamespace is the canonical IRI prefix for the JouleClaw v1
// vocabulary.
const JCNamespace = "https://jouleclaw.transaction.science/vocab/v1#"
// ─────────────────────────────────────────────────────────────────────
// Hydra surface
// ─────────────────────────────────────────────────────────────────────
// Operation mirrors jouleclaw_hydra::Operation on the wire.
type Operation struct {
ID string `json:"@id"`
Types []string `json:"@type"`
Title string `json:"title"`
Description string `json:"description"`
Method string `json:"method"`
Expects string `json:"expects"`
Returns string `json:"returns"`
StatusCodes []map[string]interface{} `json:"statusCodes"`
JCToolID *string `json:"jc_tool_id,omitempty"`
JCMutating *bool `json:"jc_mutating,omitempty"`
}
// Class is a hydra:Class — groups operations.
type Class struct {
ID string `json:"@id"`
Title string `json:"title"`
Description string `json:"description"`
SupportedOperation []Operation `json:"supportedOperation"`
}
// ApiDocumentation is the root Hydra document.
type ApiDocumentation struct {
ID string `json:"@id"`
Title string `json:"title"`
Description string `json:"description"`
SupportedClass []Class `json:"supportedClass"`
Entrypoint *string `json:"entrypoint,omitempty"`
Context interface{} `json:"@context,omitempty"`
}
// ParseAPI parses a Hydra ApiDocumentation from JSON-LD bytes.
func ParseAPI(b []byte) (*ApiDocumentation, error) {
var api ApiDocumentation
if err := json.Unmarshal(b, &api); err != nil {
return nil, fmt.Errorf("parse api: %w", err)
}
return &api, nil
}
// ─────────────────────────────────────────────────────────────────────
// SHACL Core validator (minimal subset, mirrors jouleclaw-shacl)
// ─────────────────────────────────────────────────────────────────────
// Datatype enumerates the xsd:* types the validator handles.
type Datatype string
const (
DTString Datatype = "string"
DTInteger Datatype = "integer"
DTNonNegativeInteger Datatype = "non_negative_integer"
DTDouble Datatype = "double"
DTBoolean Datatype = "boolean"
DTDateTime Datatype = "datetime"
)
// NodeKind enumerates the SHACL Core node-kind constraints.
type NodeKind string
const (
NKIRI NodeKind = "IRI"
NKLiteral NodeKind = "Literal"
NKBlankNodeOrIRI NodeKind = "BlankNodeOrIRI"
)
// PropertyShape constrains one property.
type PropertyShape struct {
Path string
MinCount *int
MaxCount *int
Datatype *Datatype
NodeKind *NodeKind
In []interface{}
Pattern *string
MinInclusive *float64
MaxInclusive *float64
Node string
}
// NodeShape constrains an instance of TargetClass.
type NodeShape struct {
TargetClass string
Properties []PropertyShape
}
// ShapeGraph holds the registered shapes keyed by TargetClass.
type ShapeGraph struct {
shapes map[string]NodeShape
}
// NewShapeGraph returns an empty graph.
func NewShapeGraph() *ShapeGraph { return &ShapeGraph{shapes: map[string]NodeShape{}} }
// Add registers a NodeShape on the graph.
func (g *ShapeGraph) Add(s NodeShape) *ShapeGraph {
g.shapes[s.TargetClass] = s
return g
}
// Get returns the registered shape for targetClass, or nil.
func (g *ShapeGraph) Get(targetClass string) *NodeShape {
if s, ok := g.shapes[targetClass]; ok {
return &s
}
return nil
}
// Violation is one constraint failure on a candidate document.
type Violation struct {
Shape string `json:"shape"`
Path string `json:"path"`
Message string `json:"message"`
}
// ValidationReport is the output of SHACL validation.
type ValidationReport struct {
Conforms bool `json:"conforms"`
Violations []Violation `json:"violations"`
}
// Validate runs the shape graph against value at targetClass.
func Validate(g *ShapeGraph, targetClass string, value interface{}) (*ValidationReport, error) {
shape := g.Get(targetClass)
if shape == nil {
return nil, fmt.Errorf("no shape registered for %s", targetClass)
}
r := &ValidationReport{Conforms: true, Violations: []Violation{}}
validateAgainst(*shape, value, g, r)
r.Conforms = len(r.Violations) == 0
return r, nil
}
func validateAgainst(shape NodeShape, value interface{}, g *ShapeGraph, r *ValidationReport) {
obj, ok := value.(map[string]interface{})
if !ok {
r.Violations = append(r.Violations, Violation{Shape: shape.TargetClass, Path: "", Message: fmt.Sprintf("expected object, got %T", value)})
return
}
for _, p := range shape.Properties {
v, present := obj[p.Path]
if !present || v == nil {
if p.MinCount != nil && *p.MinCount > 0 {
r.Violations = append(r.Violations, Violation{Shape: shape.TargetClass, Path: p.Path, Message: fmt.Sprintf("missing required property `%s`", p.Path)})
}
continue
}
checkProperty(p, v, shape, g, r)
}
}
func checkProperty(p PropertyShape, v interface{}, parent NodeShape, g *ShapeGraph, r *ValidationReport) {
if arr, isList := v.([]interface{}); isList {
n := len(arr)
if p.MinCount != nil && n < *p.MinCount {
r.Violations = append(r.Violations, viol(parent, p, fmt.Sprintf("min_count %d not met (%d present)", *p.MinCount, n)))
}
if p.MaxCount != nil && n > *p.MaxCount {
r.Violations = append(r.Violations, viol(parent, p, fmt.Sprintf("max_count %d exceeded (%d present)", *p.MaxCount, n)))
}
for _, item := range arr {
checkValue(p, item, parent, g, r)
}
return
}
checkValue(p, v, parent, g, r)
}
func checkValue(p PropertyShape, v interface{}, parent NodeShape, g *ShapeGraph, r *ValidationReport) {
if p.NodeKind != nil {
switch *p.NodeKind {
case NKIRI:
if _, ok := v.(string); !ok {
r.Violations = append(r.Violations, viol(parent, p, "node_kind IRI not satisfied"))
}
case NKLiteral:
switch v.(type) {
case string, float64, bool:
default:
r.Violations = append(r.Violations, viol(parent, p, "node_kind Literal not satisfied"))
}
case NKBlankNodeOrIRI:
switch v.(type) {
case map[string]interface{}, string:
default:
r.Violations = append(r.Violations, viol(parent, p, "node_kind BlankNodeOrIRI not satisfied"))
}
}
}
if p.Datatype != nil && !matchesDatatype(*p.Datatype, v) {
r.Violations = append(r.Violations, viol(parent, p, fmt.Sprintf("datatype %s not satisfied", *p.Datatype)))
}
if p.In != nil && !inSet(p.In, v) {
r.Violations = append(r.Violations, viol(parent, p, fmt.Sprintf("value not in allowed set (%d options)", len(p.In))))
}
if p.Pattern != nil {
if s, ok := v.(string); ok {
re, err := regexp.Compile(*p.Pattern)
if err != nil {
r.Violations = append(r.Violations, viol(parent, p, fmt.Sprintf("bad shape pattern: %s", err)))
} else if !re.MatchString(s) {
r.Violations = append(r.Violations, viol(parent, p, fmt.Sprintf("pattern `%s` not matched", *p.Pattern)))
}
}
}
if f, ok := v.(float64); ok {
if p.MinInclusive != nil && f < *p.MinInclusive {
r.Violations = append(r.Violations, viol(parent, p, fmt.Sprintf("min_inclusive %v not met (%v)", *p.MinInclusive, f)))
}
if p.MaxInclusive != nil && f > *p.MaxInclusive {
r.Violations = append(r.Violations, viol(parent, p, fmt.Sprintf("max_inclusive %v exceeded (%v)", *p.MaxInclusive, f)))
}
}
if p.Node != "" {
nested := g.Get(p.Node)
if nested == nil {
r.Violations = append(r.Violations, viol(parent, p, fmt.Sprintf("nested shape `%s` not in graph", p.Node)))
} else {
validateAgainst(*nested, v, g, r)
}
}
}
func matchesDatatype(dt Datatype, v interface{}) bool {
switch dt {
case DTString:
_, ok := v.(string)
return ok
case DTInteger, DTNonNegativeInteger, DTDouble:
// JSON numbers come in as float64. NonNegativeInteger checks >= 0.
f, ok := v.(float64)
if !ok {
return false
}
if dt == DTNonNegativeInteger && f < 0 {
return false
}
return true
case DTBoolean:
_, ok := v.(bool)
return ok
case DTDateTime:
s, ok := v.(string)
if !ok || len(s) < 19 {
return false
}
return looksLikeRFC3339(s)
}
return false
}
func looksLikeRFC3339(s string) bool {
re := regexp.MustCompile(`^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}`)
return re.MatchString(s)
}
func inSet(allowed []interface{}, v interface{}) bool {
for _, x := range allowed {
if equal(x, v) {
return true
}
}
return false
}
func equal(a, b interface{}) bool {
switch av := a.(type) {
case string:
bv, ok := b.(string)
return ok && av == bv
case float64:
bv, ok := b.(float64)
return ok && av == bv
case bool:
bv, ok := b.(bool)
return ok && av == bv
}
return false
}
func viol(parent NodeShape, p PropertyShape, msg string) Violation {
return Violation{Shape: parent.TargetClass, Path: p.Path, Message: msg}
}
// ─────────────────────────────────────────────────────────────────────
// HydraClient + Transport + CallReport
// ─────────────────────────────────────────────────────────────────────
// TransportResponse is what a Transport returns to the client.
type TransportResponse struct {
Status int
Body interface{}
// JoulesUJ is the transport's figure for the dispatch's energy, if it
// has one. nil: none, and the client emits a zero graded "Estimator".
JoulesUJ *uint64
// JoulesProvenance is the grade of the meter that produced JoulesUJ:
// "HwShunt" only for a hardware reading, "ModelBased", or "Estimator".
// Empty (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.
JoulesProvenance string
}
// GradedJoules is 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.
func (r *TransportResponse) GradedJoules() (uint64, string) {
if r.JoulesUJ == nil {
return 0, "Estimator"
}
switch r.JoulesProvenance {
case "HwShunt", "ModelBased", "Estimator":
return *r.JoulesUJ, r.JoulesProvenance
default:
return *r.JoulesUJ, "Estimator"
}
}
// Transport pluggable transport interface.
type Transport interface {
Dispatch(method, operationID string, input interface{}) (*TransportResponse, error)
}
// MockTransport — echo-mode reference; matches jouleclaw_hydra_client::MockTransport byte for byte.
type MockTransport struct {
canned map[string]*TransportResponse
}
// NewMockTransport returns an empty mock transport in echo mode.
func NewMockTransport() *MockTransport { return &MockTransport{canned: map[string]*TransportResponse{}} }
// Respond registers a canned response for a given operation id.
func (m *MockTransport) Respond(opID string, r *TransportResponse) *MockTransport {
m.canned[opID] = r
return m
}
// Dispatch implements Transport.
func (m *MockTransport) Dispatch(_method, operationID string, input interface{}) (*TransportResponse, error) {
if r, ok := m.canned[operationID]; ok {
return r, nil
}
return &TransportResponse{
Status: 200,
Body: map[string]interface{}{"echo": input},
}, nil
}
// CallReport is the per-call outcome a downstream receipt stream records.
type CallReport struct {
ToolID string `json:"tool_id"`
Status int `json:"status"`
InputHash string `json:"input_hash"`
OutputHash string `json:"output_hash"`
StatusMatchesContract bool `json:"status_matches_contract"`
Body interface{} `json:"body"`
Touch map[string]interface{} `json:"touch"`
}
// HydraClient parses + validates + dispatches.
type HydraClient struct {
API *ApiDocumentation
Shapes *ShapeGraph
}
// NewClient builds a HydraClient from an ApiDocumentation plus an optional ShapeGraph.
func NewClient(api *ApiDocumentation, shapes *ShapeGraph) *HydraClient {
if shapes == nil {
shapes = NewShapeGraph()
}
return &HydraClient{API: api, Shapes: shapes}
}
// Operations lists every operation across every supported class.
func (c *HydraClient) Operations() []Operation {
var out []Operation
for _, cls := range c.API.SupportedClass {
out = append(out, cls.SupportedOperation...)
}
return out
}
// FindOperation looks up an operation by jc_tool_id, @id, or title.
func (c *HydraClient) FindOperation(name string) *Operation {
for _, op := range c.Operations() {
op := op
if (op.JCToolID != nil && *op.JCToolID == name) || op.ID == name || op.Title == name {
return &op
}
}
return nil
}
// ValidateInput checks an input against the operation's expects-class shape.
func (c *HydraClient) ValidateInput(op *Operation, value interface{}) error {
if strings.HasSuffix(op.Expects, "#Nothing") {
return nil
}
if c.Shapes.Get(op.Expects) == nil {
return nil
}
r, err := Validate(c.Shapes, op.Expects, value)
if err != nil {
return err
}
if !r.Conforms {
return &InputInvalidError{Shape: op.Expects, Report: r}
}
return nil
}
// InputInvalidError signals a SHACL validation failure on the agent side.
type InputInvalidError struct {
Shape string
Report *ValidationReport
}
func (e *InputInvalidError) Error() string {
return fmt.Sprintf("input does not validate against shape `%s` (%d violation(s))",
e.Shape, len(e.Report.Violations))
}
// Call dispatches op against transport — validates first; on failure
// returns without ever calling the transport.
func (c *HydraClient) Call(opName string, input interface{}, transport Transport) (*CallReport, error) {
op := c.FindOperation(opName)
if op == nil {
return nil, fmt.Errorf("operation `%s` not found", opName)
}
if err := c.ValidateInput(op, input); err != nil {
return nil, err
}
resp, err := transport.Dispatch(op.Method, op.ID, input)
if err != nil {
return nil, fmt.Errorf("transport: %w", err)
}
toolID := op.ID
if op.JCToolID != nil {
toolID = *op.JCToolID
}
inputHash := blake3Hex(CanonicalJSON(input))
outputHash := blake3Hex(CanonicalJSON(resp.Body))
matches := false
for _, sc := range op.StatusCodes {
if code, ok := sc["code"].(float64); ok && int(code) == resp.Status {
matches = true
break
}
}
joules, provenance := resp.GradedJoules()
return &CallReport{
ToolID: toolID,
Status: resp.Status,
InputHash: inputHash,
OutputHash: outputHash,
StatusMatchesContract: matches,
Body: resp.Body,
Touch: map[string]interface{}{
"tool_id": toolID,
"joules_uj": joules,
"energy_provenance": provenance,
},
}, nil
}
// ─────────────────────────────────────────────────────────────────────
// Canonical JSON + BLAKE3 — bytes the three implementations agree on
// ─────────────────────────────────────────────────────────────────────
// CanonicalJSON returns a lexically-sorted, no-whitespace JSON
// serialisation. Matches the Rust client's canonical form so the
// BLAKE3 hash is identical across implementations.
func CanonicalJSON(value interface{}) []byte {
var buf strings.Builder
writeCanonical(&buf, value)
return []byte(buf.String())
}
func writeCanonical(buf *strings.Builder, value interface{}) {
switch v := value.(type) {
case nil:
buf.WriteString("null")
case bool:
if v {
buf.WriteString("true")
} else {
buf.WriteString("false")
}
case float64:
// JSON numbers — encoding/json will produce the same form
// the Rust client emits for integers.
b, _ := json.Marshal(v)
buf.Write(b)
case string:
b, _ := json.Marshal(v)
buf.Write(b)
case []interface{}:
buf.WriteString("[")
for i, item := range v {
if i > 0 {
buf.WriteString(",")
}
writeCanonical(buf, item)
}
buf.WriteString("]")
case map[string]interface{}:
keys := make([]string, 0, len(v))
for k := range v {
keys = append(keys, k)
}
sort.Strings(keys)
buf.WriteString("{")
for i, k := range keys {
if i > 0 {
buf.WriteString(",")
}
b, _ := json.Marshal(k)
buf.Write(b)
buf.WriteString(":")
writeCanonical(buf, v[k])
}
buf.WriteString("}")
default:
// Fall back to json.Marshal for anything else — same shape
// the Rust serde produces.
b, _ := json.Marshal(value)
buf.Write(b)
}
}
func blake3Hex(b []byte) string {
h := blake3.Sum256(b)
return hex.EncodeToString(h[:])
}