@pi-in-go/pigpen-jev 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. package/CREDITS.md +22 -0
  2. package/LICENSE +22 -0
  3. package/README.md +237 -0
  4. package/extensions/jev/ask.go +166 -0
  5. package/extensions/jev/ask_test.go +218 -0
  6. package/extensions/jev/backend.go +128 -0
  7. package/extensions/jev/bench_test.go +64 -0
  8. package/extensions/jev/boundaries_test.go +159 -0
  9. package/extensions/jev/command.go +224 -0
  10. package/extensions/jev/commands_test.go +214 -0
  11. package/extensions/jev/config.go +450 -0
  12. package/extensions/jev/errors_test.go +191 -0
  13. package/extensions/jev/extension.go +391 -0
  14. package/extensions/jev/fakehost_test.go +548 -0
  15. package/extensions/jev/gate.go +125 -0
  16. package/extensions/jev/gate_test.go +610 -0
  17. package/extensions/jev/gatekey_test.go +24 -0
  18. package/extensions/jev/go.mod +9 -0
  19. package/extensions/jev/go.sum +2 -0
  20. package/extensions/jev/go.work +10 -0
  21. package/extensions/jev/helpers_test.go +404 -0
  22. package/extensions/jev/memo.go +88 -0
  23. package/extensions/jev/output.go +89 -0
  24. package/extensions/jev/output_test.go +187 -0
  25. package/extensions/jev/ownmodel_test.go +118 -0
  26. package/extensions/jev/render.go +136 -0
  27. package/extensions/jev/review_test.go +310 -0
  28. package/extensions/jev/source_test.go +57 -0
  29. package/extensions/jev/text.go +174 -0
  30. package/extensions/jev/trust_test.go +335 -0
  31. package/extensions/jev/types.go +227 -0
  32. package/libs/typesafe/CONTRACT.md +125 -0
  33. package/libs/typesafe/CREDITS.md +37 -0
  34. package/libs/typesafe/LICENSE +23 -0
  35. package/libs/typesafe/README.md +19 -0
  36. package/libs/typesafe/go.mod +3 -0
  37. package/libs/typesafe/libraries/ownmodel/backend_test.go +496 -0
  38. package/libs/typesafe/libraries/ownmodel/canon.go +190 -0
  39. package/libs/typesafe/libraries/ownmodel/convert.go +199 -0
  40. package/libs/typesafe/libraries/ownmodel/doc.go +15 -0
  41. package/libs/typesafe/libraries/ownmodel/equivalence_test.go +199 -0
  42. package/libs/typesafe/libraries/ownmodel/helpers_test.go +155 -0
  43. package/libs/typesafe/libraries/ownmodel/mutation_test.go +31 -0
  44. package/libs/typesafe/libraries/ownmodel/ownmodel.go +225 -0
  45. package/libs/typesafe/libraries/ownmodel/plan.go +442 -0
  46. package/libs/typesafe/libraries/ownmodel/run.go +288 -0
  47. package/libs/typesafe/libraries/ownmodel/schema_test.go +254 -0
  48. package/libs/typesafe/libraries/ownmodel/twins_test.go +169 -0
  49. package/libs/typesafe/libraries/ownmodel/utils_test.go +125 -0
  50. package/libs/typesafe/libraries/pigmodel/pigmodel.go +264 -0
  51. package/libs/typesafe/libraries/pigmodel/pigmodel_test.go +410 -0
  52. package/libs/typesafe/libraries/typesafe/answers.go +268 -0
  53. package/libs/typesafe/libraries/typesafe/api_response_test.go +113 -0
  54. package/libs/typesafe/libraries/typesafe/batch.go +80 -0
  55. package/libs/typesafe/libraries/typesafe/batch_test.go +133 -0
  56. package/libs/typesafe/libraries/typesafe/bench_test.go +71 -0
  57. package/libs/typesafe/libraries/typesafe/client.go +561 -0
  58. package/libs/typesafe/libraries/typesafe/client_test.go +495 -0
  59. package/libs/typesafe/libraries/typesafe/crosscheck_test.go +464 -0
  60. package/libs/typesafe/libraries/typesafe/crosscheck_workflowevals_test.go +219 -0
  61. package/libs/typesafe/libraries/typesafe/doc.go +27 -0
  62. package/libs/typesafe/libraries/typesafe/entry.go +142 -0
  63. package/libs/typesafe/libraries/typesafe/env.go +11 -0
  64. package/libs/typesafe/libraries/typesafe/errors.go +310 -0
  65. package/libs/typesafe/libraries/typesafe/errors_test.go +175 -0
  66. package/libs/typesafe/libraries/typesafe/helpers_test.go +294 -0
  67. package/libs/typesafe/libraries/typesafe/live_test.go +96 -0
  68. package/libs/typesafe/libraries/typesafe/logging.go +160 -0
  69. package/libs/typesafe/libraries/typesafe/logging_test.go +259 -0
  70. package/libs/typesafe/libraries/typesafe/marshal_test.go +112 -0
  71. package/libs/typesafe/libraries/typesafe/mutation_test.go +39 -0
  72. package/libs/typesafe/libraries/typesafe/questions.go +490 -0
  73. package/libs/typesafe/libraries/typesafe/questions_test.go +166 -0
  74. package/libs/typesafe/libraries/typesafe/regressions_test.go +159 -0
  75. package/libs/typesafe/libraries/typesafe/reliability_test.go +649 -0
  76. package/libs/typesafe/libraries/typesafe/retry.go +350 -0
  77. package/libs/typesafe/libraries/typesafe/retry_test.go +297 -0
  78. package/libs/typesafe/libraries/typesafe/runtime_test.go +26 -0
  79. package/libs/typesafe/libraries/typesafe/transport_test.go +163 -0
  80. package/libs/typesafe/libraries/typesafe/twins_test.go +127 -0
  81. package/libs/typesafe/libraries/typesafe/types_test.go +165 -0
  82. package/libs/typesafe/libraries/typesafe/version.go +10 -0
  83. package/libs/typesafe/package.json +37 -0
  84. package/libs/typesafe/provenance.json +49 -0
  85. package/package.json +42 -0
  86. package/port/PORT.md +107 -0
  87. package/port/e2e/gate-and-output.py +35 -0
  88. package/port/e2e/jev-ask.py +36 -0
  89. package/port/e2e/model-switch.py +44 -0
  90. package/port/e2e/off-by-default.py +34 -0
  91. package/port/gen-scenarios.py +103 -0
  92. package/port/golden/cache-identical-calls.jsonl +30 -0
  93. package/port/golden/clear.jsonl +22 -0
  94. package/port/golden/commands.jsonl +43 -0
  95. package/port/golden/enforce-accept.jsonl +23 -0
  96. package/port/golden/enforce-decline.jsonl +22 -0
  97. package/port/golden/jev-ask.jsonl +20 -0
  98. package/port/golden/output-advice.jsonl +23 -0
  99. package/port/golden/output-leak.jsonl +24 -0
  100. package/port/golden/output-low-confidence.jsonl +22 -0
  101. package/port/golden/shadow-flagged.jsonl +23 -0
  102. package/port/golden/unjudged-tools.jsonl +19 -0
  103. package/port/golden/write-elision.jsonl +21 -0
  104. package/port/mutate-unit.py +63 -0
  105. package/port/mutations.json +578 -0
  106. package/port/oracle/LICENSE +21 -0
  107. package/port/oracle/README.md +181 -0
  108. package/port/oracle/SHA256SUMS +8 -0
  109. package/port/oracle/package.json +43 -0
  110. package/port/oracle/src/client.ts +409 -0
  111. package/port/oracle/src/config.ts +363 -0
  112. package/port/oracle/src/gate.ts +229 -0
  113. package/port/oracle/src/index.ts +649 -0
  114. package/port/oracle/src/output.ts +163 -0
  115. package/port/red-run.log +309 -0
  116. package/port/scenarios/cache-identical-calls.json +71 -0
  117. package/port/scenarios/clear.json +61 -0
  118. package/port/scenarios/commands.json +119 -0
  119. package/port/scenarios/enforce-accept.json +66 -0
  120. package/port/scenarios/enforce-decline.json +57 -0
  121. package/port/scenarios/jev-ask.json +83 -0
  122. package/port/scenarios/output-advice.json +61 -0
  123. package/port/scenarios/output-leak.json +61 -0
  124. package/port/scenarios/output-low-confidence.json +61 -0
  125. package/port/scenarios/shadow-flagged.json +61 -0
  126. package/port/scenarios/unjudged-tools.json +55 -0
  127. package/port/scenarios/write-elision.json +53 -0
  128. package/provenance.json +18 -0
@@ -0,0 +1,190 @@
1
+ package ownmodel
2
+
3
+ import (
4
+ "bytes"
5
+ "encoding/json"
6
+ "fmt"
7
+ "io"
8
+ "sort"
9
+ "strconv"
10
+ "strings"
11
+ )
12
+
13
+ // The Python oracle serializes with pydantic_core.to_json: compact, no ASCII escaping, key
14
+ // order kept. These helpers reproduce it so that prompts match the oracle byte for byte.
15
+
16
+ // writeJSONString writes s like serde_json: only the quote, the backslash and the
17
+ // control characters are escaped.
18
+ func writeJSONString(b *strings.Builder, s string) {
19
+ b.WriteByte('"')
20
+ for _, r := range s {
21
+ switch r {
22
+ case '"':
23
+ b.WriteString(`\"`)
24
+ case '\\':
25
+ b.WriteString(`\\`)
26
+ case '\b':
27
+ b.WriteString(`\b`)
28
+ case '\f':
29
+ b.WriteString(`\f`)
30
+ case '\n':
31
+ b.WriteString(`\n`)
32
+ case '\r':
33
+ b.WriteString(`\r`)
34
+ case '\t':
35
+ b.WriteString(`\t`)
36
+ default:
37
+ if r < 0x20 {
38
+ fmt.Fprintf(b, `\u%04x`, r)
39
+ } else {
40
+ b.WriteRune(r)
41
+ }
42
+ }
43
+ }
44
+ b.WriteByte('"')
45
+ }
46
+
47
+ // canonicalJSON re-encodes JSON text the way pydantic_core.to_json encodes the parsed
48
+ // value: compact, key order kept, strings escaped like serde_json. Number literals are
49
+ // kept as written.
50
+ func canonicalJSON(raw []byte) (string, error) {
51
+ dec := json.NewDecoder(bytes.NewReader(raw))
52
+ dec.UseNumber()
53
+ var b strings.Builder
54
+ if err := canonValue(dec, &b); err != nil {
55
+ return "", err
56
+ }
57
+ if _, err := dec.Token(); err != io.EOF {
58
+ return "", fmt.Errorf("unexpected data after the JSON value")
59
+ }
60
+ return b.String(), nil
61
+ }
62
+
63
+ func canonValue(dec *json.Decoder, b *strings.Builder) error {
64
+ tok, err := dec.Token()
65
+ if err != nil {
66
+ return err
67
+ }
68
+ switch v := tok.(type) {
69
+ case json.Delim:
70
+ switch v {
71
+ case '{':
72
+ b.WriteByte('{')
73
+ first := true
74
+ for dec.More() {
75
+ kt, err := dec.Token()
76
+ if err != nil {
77
+ return err
78
+ }
79
+ if !first {
80
+ b.WriteByte(',')
81
+ }
82
+ first = false
83
+ writeJSONString(b, kt.(string))
84
+ b.WriteByte(':')
85
+ if err := canonValue(dec, b); err != nil {
86
+ return err
87
+ }
88
+ }
89
+ if _, err := dec.Token(); err != nil {
90
+ return err
91
+ }
92
+ b.WriteByte('}')
93
+ case '[':
94
+ b.WriteByte('[')
95
+ first := true
96
+ for dec.More() {
97
+ if !first {
98
+ b.WriteByte(',')
99
+ }
100
+ first = false
101
+ if err := canonValue(dec, b); err != nil {
102
+ return err
103
+ }
104
+ }
105
+ if _, err := dec.Token(); err != nil {
106
+ return err
107
+ }
108
+ b.WriteByte(']')
109
+ }
110
+ case string:
111
+ writeJSONString(b, v)
112
+ case json.Number:
113
+ b.WriteString(v.String())
114
+ case bool:
115
+ b.WriteString(strconv.FormatBool(v))
116
+ case nil:
117
+ b.WriteString("null")
118
+ }
119
+ return nil
120
+ }
121
+
122
+ // kv and obj are an ordered JSON object, so schemas keep pydantic's key order.
123
+ type kv struct {
124
+ k string
125
+ v any
126
+ }
127
+
128
+ type obj []kv
129
+
130
+ func writeAny(b *strings.Builder, v any) {
131
+ switch x := v.(type) {
132
+ case obj:
133
+ b.WriteByte('{')
134
+ for i, e := range x {
135
+ if i > 0 {
136
+ b.WriteByte(',')
137
+ }
138
+ writeJSONString(b, e.k)
139
+ b.WriteByte(':')
140
+ writeAny(b, e.v)
141
+ }
142
+ b.WriteByte('}')
143
+ case []string:
144
+ b.WriteByte('[')
145
+ for i, e := range x {
146
+ if i > 0 {
147
+ b.WriteByte(',')
148
+ }
149
+ writeJSONString(b, e)
150
+ }
151
+ b.WriteByte(']')
152
+ case string:
153
+ writeJSONString(b, x)
154
+ case bool:
155
+ b.WriteString(strconv.FormatBool(x))
156
+ default:
157
+ panic(fmt.Sprintf("writeAny: unsupported %T", v))
158
+ }
159
+ }
160
+
161
+ // toMap converts an ordered object to the generic form handed to a Model.
162
+ func toMap(v any) any {
163
+ switch x := v.(type) {
164
+ case obj:
165
+ m := make(map[string]any, len(x))
166
+ for _, e := range x {
167
+ m[e.k] = toMap(e.v)
168
+ }
169
+ return m
170
+ case []string:
171
+ out := make([]any, len(x))
172
+ for i, s := range x {
173
+ out[i] = s
174
+ }
175
+ return out
176
+ }
177
+ return v
178
+ }
179
+
180
+ func sortedKeys[V any](m map[string]V) []string {
181
+ keys := make([]string, 0, len(m))
182
+ for k := range m {
183
+ keys = append(keys, k)
184
+ }
185
+ sort.Strings(keys)
186
+ return keys
187
+ }
188
+
189
+ // validUTF8 replaces invalid bytes so that a prompt is always valid UTF-8 (Python str cannot hold them).
190
+ func validUTF8(s string) string { return strings.ToValidUTF8(s, "\uFFFD") }
@@ -0,0 +1,199 @@
1
+ package ownmodel
2
+
3
+ import (
4
+ "math"
5
+
6
+ "github.com/MichaelKinsy/pigpen/components/typesafe/libraries/typesafe"
7
+ )
8
+
9
+ // probabilityTolerance is the largest deviation of a probability sum from 1 that is not
10
+ // reported as invalid (the oracle's PROBABILITY_TOLERANCE).
11
+ const probabilityTolerance = 1e-6
12
+
13
+ // normalizeConfidence returns probs as a distribution; a zero total gives a uniform one.
14
+ func normalizeConfidence(probs []float64) []float64 {
15
+ total := 0.0
16
+ for _, p := range probs {
17
+ total += p
18
+ }
19
+ out := make([]float64, len(probs))
20
+ for i, p := range probs {
21
+ if total == 0 {
22
+ out[i] = 1 / float64(len(probs))
23
+ } else {
24
+ out[i] = p / total
25
+ }
26
+ }
27
+ return out
28
+ }
29
+
30
+ // scoreConfidence measures score concentration around its modal score.
31
+ func scoreConfidence(probs []float64) float64 {
32
+ if len(probs) == 1 {
33
+ return 1
34
+ }
35
+ n := normalizeConfidence(probs)
36
+ mode := 0
37
+ for i := range n {
38
+ if n[i] > n[mode] { // the first maximum, like Python's max
39
+ mode = i
40
+ }
41
+ }
42
+ distance := 0.0
43
+ for i, p := range n {
44
+ distance += p * math.Abs(float64(i-mode))
45
+ }
46
+ center := float64(len(n)-1) / 2
47
+ deviation := 0.0
48
+ for i := range n {
49
+ deviation += math.Abs(float64(i) - center)
50
+ }
51
+ deviation /= float64(len(n))
52
+ return math.Max(0, 1-distance/deviation)
53
+ }
54
+
55
+ // choiceConfidence scales peak choice probability from uniform to certainty.
56
+ func choiceConfidence(probs []float64) float64 {
57
+ if len(probs) == 1 {
58
+ return 1
59
+ }
60
+ n := normalizeConfidence(probs)
61
+ peak := n[0]
62
+ for _, p := range n {
63
+ peak = math.Max(peak, p)
64
+ }
65
+ uniform := 1 / float64(len(n))
66
+ return (peak - uniform) / (1 - uniform)
67
+ }
68
+
69
+ // probNorm is the probability distribution of one answer, its error against a sum of 1,
70
+ // and the original distribution when normalization changed it.
71
+ type probNorm struct {
72
+ Order []string
73
+ Probabilities map[string]float64
74
+ Error float64
75
+ Original map[string]float64
76
+ }
77
+
78
+ // rescale rescales probabilities to sum to 1, falling back to uniform for a zero total.
79
+ func rescale(order []string, probs map[string]float64) map[string]float64 {
80
+ total := 0.0
81
+ for _, a := range order {
82
+ total += probs[a]
83
+ }
84
+ out := make(map[string]float64, len(order))
85
+ for _, a := range order {
86
+ if total == 0 {
87
+ out[a] = 1 / float64(len(order))
88
+ } else {
89
+ out[a] = probs[a] / total
90
+ }
91
+ }
92
+ return out
93
+ }
94
+
95
+ // normalizeProbabilities builds and optionally rescales a distribution. In discrete mode
96
+ // selected is the chosen answer; in probabilities mode probs is the model's distribution.
97
+ func normalizeProbabilities(answers []string, probs map[string]float64, selected string, mode AnswerMode, enabled bool) probNorm {
98
+ if mode == Discrete {
99
+ out := make(map[string]float64, len(answers))
100
+ for _, a := range answers {
101
+ out[a] = 0
102
+ if a == selected {
103
+ out[a] = 1
104
+ }
105
+ }
106
+ return probNorm{Order: answers, Probabilities: out}
107
+ }
108
+ original := make(map[string]float64, len(answers))
109
+ total := 0.0
110
+ for _, a := range answers {
111
+ original[a] = probs[a]
112
+ total += probs[a]
113
+ }
114
+ errAmount := math.Abs(total - 1)
115
+ if !enabled || errAmount <= probabilityTolerance {
116
+ return probNorm{Order: answers, Probabilities: original, Error: errAmount}
117
+ }
118
+ return probNorm{Order: answers, Probabilities: rescale(answers, original), Error: errAmount, Original: original}
119
+ }
120
+
121
+ // probDebug is the probability diagnostics of an evaluation.
122
+ type probDebug struct {
123
+ MaxError float64
124
+ InvalidProbs int
125
+ ProbabilityErrors map[string]float64
126
+ Original map[string]map[string]float64
127
+ }
128
+
129
+ // probabilityDebugData summarizes normalizations keyed by question name (nil for a Noul).
130
+ func probabilityDebugData(norms map[string]*probNorm) probDebug {
131
+ d := probDebug{ProbabilityErrors: map[string]float64{}, Original: map[string]map[string]float64{}}
132
+ for name, n := range norms {
133
+ if n == nil {
134
+ continue
135
+ }
136
+ d.MaxError = math.Max(d.MaxError, n.Error)
137
+ if n.Error > probabilityTolerance {
138
+ d.ProbabilityErrors[name] = n.Error
139
+ }
140
+ if n.Original != nil {
141
+ d.Original[name] = n.Original
142
+ }
143
+ }
144
+ d.InvalidProbs = len(d.ProbabilityErrors)
145
+ return d
146
+ }
147
+
148
+ // convert turns a validated output into typed answers and the normalizations of the
149
+ // Score and Choice answers.
150
+ func (p *plan) convert(d *decoded, normalize bool) (map[string]typesafe.Answer, map[string]*probNorm, error) {
151
+ answers := make(map[string]typesafe.Answer, len(p.questions))
152
+ norms := make(map[string]*probNorm, len(p.questions))
153
+ for i := range p.questions {
154
+ q := &p.questions[i]
155
+ v := d.answers[q.name]
156
+ norms[q.name] = nil
157
+ switch q.kind {
158
+ case typesafe.TypeNoul:
159
+ prob := v.prob
160
+ if p.mode == Discrete {
161
+ prob = 0
162
+ if v.flag {
163
+ prob = 1
164
+ }
165
+ }
166
+ answers[q.name] = typesafe.NoulAnswer{Noul: prob}
167
+ case typesafe.TypeScore:
168
+ n := normalizeProbabilities(q.labels, v.probs, v.selected, p.mode, normalize)
169
+ norms[q.name] = &n
170
+ // The score is an expected value, meaningful only over a distribution summing to 1:
171
+ // rescale here, whatever normalize says about the reported probabilities.
172
+ dist := rescale(q.labels, n.Probabilities)
173
+ score := 0.0
174
+ ordered := make([]float64, len(q.labels))
175
+ probs := make(map[int]float64, len(q.labels))
176
+ legend := make(map[int]any, len(q.labels))
177
+ for j, label := range q.labels {
178
+ score += float64(j) * dist[label]
179
+ ordered[j] = n.Probabilities[label]
180
+ probs[j] = n.Probabilities[label]
181
+ legend[j] = q.legend[j]
182
+ }
183
+ answers[q.name] = typesafe.ScoreAnswer{Score: score, Confidence: scoreConfidence(ordered), Probabilities: probs, Legend: legend}
184
+ case typesafe.TypeChoice:
185
+ n := normalizeProbabilities(q.labels, v.probs, v.selected, p.mode, normalize)
186
+ norms[q.name] = &n
187
+ best := 0
188
+ ordered := make([]float64, len(q.labels))
189
+ for j, label := range q.labels {
190
+ ordered[j] = n.Probabilities[label]
191
+ if n.Probabilities[label] > n.Probabilities[q.labels[best]] {
192
+ best = j
193
+ }
194
+ }
195
+ answers[q.name] = typesafe.ChoiceAnswer{Choice: q.labels[best], Confidence: choiceConfidence(ordered), Probabilities: n.Probabilities}
196
+ }
197
+ }
198
+ return answers, norms, nil
199
+ }
@@ -0,0 +1,15 @@
1
+ // Package ownmodel answers the same typed questions as the TypeSafe API with the
2
+ // model PiG is configured with. It is a port of system-one-adapter-python
3
+ // (https://github.com/typesafe-ai/system-one-adapter-python, MIT): the same prompts,
4
+ // the same per-question answer schemas, probability and discrete answer modes,
5
+ // probability normalization, confidence metrics, corrective retries on malformed
6
+ // output, and transient retries through the typesafe retry policy.
7
+ //
8
+ // A [Backend] implements typesafe.Evaluator, so an extension can hold one
9
+ // typesafe.Evaluator and pick the TypeSafe API or the own model by configuration.
10
+ // No provider or model name is built in: the model is whatever the [Model] the
11
+ // caller passes is; package pigmodel provides a Model backed by the PiG Go SDK's
12
+ // ModelRegistry (the model PiG is configured with, its auth, its provider layer).
13
+ //
14
+ // Content is sent to the configured model's provider, not to TypeSafe.
15
+ package ownmodel
@@ -0,0 +1,199 @@
1
+ package ownmodel
2
+
3
+ import (
4
+ "bytes"
5
+ "context"
6
+ "encoding/json"
7
+ "math"
8
+ "os"
9
+ "path/filepath"
10
+ "strings"
11
+ "testing"
12
+
13
+ "github.com/MichaelKinsy/pigpen/components/typesafe/libraries/typesafe"
14
+ )
15
+
16
+ // The differential check against the Python oracle: port/equivalence/scenarios/*.json were
17
+ // run through the unmodified system-one-adapter 0.2.1 (e1d4cc9) by record_python.py, which
18
+ // recorded golden/*.json. Here the same scenarios run through the Go backend with the same
19
+ // scripted model responses, and the model calls (prompts and JSON Schema), the answers, the
20
+ // usage counters and the diagnostics must be the same.
21
+
22
+ type scenario struct {
23
+ Name string `json:"name"`
24
+ Mode string `json:"mode"`
25
+ Structured bool `json:"structured"`
26
+ Normalize bool `json:"normalize"`
27
+ MalformedRetries int `json:"malformedRetries"`
28
+ State json.RawMessage `json:"state"`
29
+ Questions json.RawMessage `json:"questions"`
30
+ Responses []json.RawMessage `json:"responses"`
31
+ }
32
+
33
+ type golden struct {
34
+ Calls []struct {
35
+ Messages []Message `json:"messages"`
36
+ Schema map[string]any `json:"schema"`
37
+ Structured bool `json:"structured"`
38
+ } `json:"calls"`
39
+ Answers map[string]any `json:"answers"`
40
+ Usage map[string]any `json:"usage"`
41
+ Debug map[string]any `json:"debug"`
42
+ RetryReasons []string `json:"retryReasons"`
43
+ Error *struct {
44
+ Type string `json:"type"`
45
+ Message string `json:"message"`
46
+ } `json:"error"`
47
+ }
48
+
49
+ func loadJSON(t *testing.T, path string, v any) {
50
+ t.Helper()
51
+ raw, err := os.ReadFile(path)
52
+ noErr(t, err)
53
+ noErr(t, json.Unmarshal(raw, v))
54
+ }
55
+
56
+ const (
57
+ correctionPrefix = "The previous response did not match the required schema: "
58
+ correctionSuffix = "\nReturn a single JSON object that matches the schema exactly, with no other text."
59
+ )
60
+
61
+ func TestEquivalence_GoBackendMatchesThePythonOracle(t *testing.T) {
62
+ dir := filepath.Join("..", "..", "port", "equivalence")
63
+ files, err := filepath.Glob(filepath.Join(dir, "scenarios", "*.json"))
64
+ noErr(t, err)
65
+ if len(files) < 15 {
66
+ t.Fatalf("expected the recorded scenarios, found %d", len(files))
67
+ }
68
+ for _, file := range files {
69
+ var sc scenario
70
+ loadJSON(t, file, &sc)
71
+ var g golden
72
+ loadJSON(t, filepath.Join(dir, "golden", filepath.Base(file)), &g)
73
+ t.Run(sc.Name, func(t *testing.T) {
74
+ qs, err := typesafe.ParseQuestions(sc.Questions)
75
+ noErr(t, err)
76
+ var steps []any
77
+ for _, r := range sc.Responses {
78
+ if bytes.HasPrefix(bytes.TrimSpace(r), []byte(`"`)) {
79
+ var s string
80
+ noErr(t, json.Unmarshal(r, &s))
81
+ steps = append(steps, s)
82
+ } else {
83
+ var buf bytes.Buffer
84
+ noErr(t, json.Compact(&buf, r))
85
+ steps = append(steps, buf.String())
86
+ }
87
+ }
88
+ model := newScripted(steps...)
89
+ b := mustNew(t, Options{Model: model, AnswerMode: AnswerMode(sc.Mode), StructuredOutputs: sc.Structured, NormalizeProbabilities: sc.Normalize, MalformedRetries: sc.MalformedRetries})
90
+ ev, err := b.Evaluate(context.Background(), typesafe.SystemOneRequest{State: typesafe.Value(sc.State), Questions: qs}, nil)
91
+
92
+ // The model calls: prompts and schema.
93
+ eq(t, len(model.calls), len(g.Calls))
94
+ for i, want := range g.Calls {
95
+ got := model.calls[i]
96
+ eq(t, len(got), len(want.Messages))
97
+ for j, m := range want.Messages {
98
+ if m.Role == RoleUser && strings.HasPrefix(m.Content, correctionPrefix) {
99
+ // The validation error text is this port's own; the frame around it is the oracle's.
100
+ if got[j].Role != RoleUser || !strings.HasPrefix(got[j].Content, correctionPrefix) || !strings.HasSuffix(got[j].Content, correctionSuffix) {
101
+ t.Fatalf("call %d message %d: correction frame differs:\n%q", i, j, got[j].Content)
102
+ }
103
+ continue
104
+ }
105
+ if got[j] != m {
106
+ t.Fatalf("call %d message %d differs\n got: %s\nwant: %s", i, j, got[j].Content, m.Content)
107
+ }
108
+ }
109
+ eq(t, model.structured[i], want.Structured)
110
+ sameJSON(t, model.schemas[i], want.Schema, "schema of call "+string(rune('0'+i)))
111
+ }
112
+
113
+ if g.Error != nil {
114
+ if err == nil {
115
+ t.Fatalf("the oracle failed with %s, Go succeeded", g.Error.Type)
116
+ }
117
+ eq(t, g.Error.Type, "TypeSafeAPIResponseValidationError")
118
+ mustAs[*MalformedOutputError](t, err)
119
+ de := mustAs[*DebugError](t, err)
120
+ eq(t, categories(de.Debug.RetryReasons), g.RetryReasons)
121
+ return
122
+ }
123
+ noErr(t, err)
124
+ raw, err := json.Marshal(ev.Result.Answers)
125
+ noErr(t, err)
126
+ var answers map[string]any
127
+ noErr(t, json.Unmarshal(raw, &answers))
128
+ sameJSON(t, answers, g.Answers, "answers")
129
+ usage := map[string]any{
130
+ "input_tokens": intOrNil(ev.Usage.InputTokens), "output_tokens": intOrNil(ev.Usage.OutputTokens),
131
+ "input_tokens_total": intOrNil(ev.Usage.InputTokensTotal), "output_tokens_total": intOrNil(ev.Usage.OutputTokensTotal),
132
+ "n_retries": float64(ev.Usage.Retries), "n_retries_malformed_structure": float64(ev.Usage.MalformedRetries),
133
+ }
134
+ sameJSON(t, usage, g.Usage, "usage")
135
+ eq(t, categories(ev.Debug.RetryReasons), g.RetryReasons)
136
+ dbg := map[string]any{"max_error": ev.Debug.MaxError, "invalid_probs": float64(ev.Debug.InvalidProbs), "probability_errors": toAny(ev.Debug.ProbabilityErrors)}
137
+ if len(ev.Debug.OriginalProbabilities) > 0 {
138
+ dbg["original_probabilities"] = toAny(ev.Debug.OriginalProbabilities)
139
+ }
140
+ sameJSON(t, dbg, g.Debug, "debug")
141
+ })
142
+ }
143
+ }
144
+
145
+ func intOrNil(p *int) any {
146
+ if p == nil {
147
+ return nil
148
+ }
149
+ return float64(*p)
150
+ }
151
+
152
+ func toAny(v any) any {
153
+ raw, _ := json.Marshal(v)
154
+ var out any
155
+ _ = json.Unmarshal(raw, &out)
156
+ return out
157
+ }
158
+
159
+ // sameJSON compares two decoded JSON values, numbers within a relative 1e-12.
160
+ func sameJSON(t *testing.T, got, want any, what string) {
161
+ t.Helper()
162
+ if !jsonEqual(toAny(got), toAny(want)) {
163
+ g, _ := json.MarshalIndent(got, "", " ")
164
+ w, _ := json.MarshalIndent(want, "", " ")
165
+ t.Fatalf("%s differs\n got: %s\nwant: %s", what, g, w)
166
+ }
167
+ }
168
+
169
+ func jsonEqual(a, b any) bool {
170
+ switch x := a.(type) {
171
+ case float64:
172
+ y, ok := b.(float64)
173
+ return ok && math.Abs(x-y) <= 1e-12*math.Max(1, math.Abs(y))
174
+ case map[string]any:
175
+ y, ok := b.(map[string]any)
176
+ if !ok || len(x) != len(y) {
177
+ return false
178
+ }
179
+ for k, v := range x {
180
+ w, ok := y[k]
181
+ if !ok || !jsonEqual(v, w) {
182
+ return false
183
+ }
184
+ }
185
+ return true
186
+ case []any:
187
+ y, ok := b.([]any)
188
+ if !ok || len(x) != len(y) {
189
+ return false
190
+ }
191
+ for i := range x {
192
+ if !jsonEqual(x[i], y[i]) {
193
+ return false
194
+ }
195
+ }
196
+ return true
197
+ }
198
+ return a == b
199
+ }