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]>
568 lines
18 KiB
Go
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[:])
|
|
}
|