jury.gno
7.50 Kb · 276 lines
1// Package jury is a commit-reveal adversarial review protocol for disputes
2// over agent work.
3//
4// The hard part of an agent economy is not paying agents — it is deciding
5// what happens when an agent's output is disputed. This realm runs a small
6// jury: a fixed panel of reviewers each *commit* to a hidden verdict, then
7// *reveal* it. Commit-reveal means no juror can copy another's vote or be
8// swayed after seeing the tally; every verdict is locked in blind.
9//
10// The subject of a case is an opaque string — a receipt sequence, a claim
11// id, an off-chain artifact hash — so this realm composes with the receipt
12// and gnomem demos without depending on them.
13//
14// Bonding and slashing (a juror stakes coins, loses them for provably bad
15// verdicts) are the natural economic layer on top; this demo shows the
16// mechanism without the money.
17package jury
18
19import (
20 "chain"
21 "chain/runtime"
22 "strings"
23
24 "gno.land/p/g1manfred47kzduec920z88wfr64ylksmdcedlf5/agents/commit"
25 "gno.land/p/nt/ufmt/v0"
26)
27
28// Phase is a case's position in the commit-reveal lifecycle.
29type Phase string
30
31const (
32 PhaseCommit Phase = "commit"
33 PhaseReveal Phase = "reveal"
34 PhaseClosed Phase = "closed"
35)
36
37// Juror is one panelist and their (eventually revealed) verdict.
38type Juror struct {
39 Addr address
40 Commitment string // commit.Verdict(verdict, salt)
41 Committed bool
42 Revealed bool
43 Verdict bool // meaningful once Revealed
44 Height int64
45}
46
47// Case is a single dispute under review by a fixed panel.
48type Case struct {
49 ID uint64
50 Subject string // opaque reference to the disputed thing
51 Opener address
52 Phase Phase
53 Jurors []*Juror
54 OpenedAt int64
55 Outcome string // "upheld" | "rejected" | "tie" once closed
56 Yes int
57 No int
58}
59
60var cases []*Case // index 0 => ID 1
61
62// OpenCase starts a dispute with a fixed panel of jurors. An odd panel size
63// avoids ties; duplicates are rejected.
64func OpenCase(cur realm, subject string, jurors []address) uint64 {
65 assert(subject != "", "empty subject")
66 assert(len(jurors) >= 1, "need at least one juror")
67
68 seen := make(map[string]bool)
69 panel := make([]*Juror, 0, len(jurors))
70 for _, j := range jurors {
71 key := j.String()
72 assert(!seen[key], "duplicate juror: "+key)
73 seen[key] = true
74 panel = append(panel, &Juror{Addr: j})
75 }
76
77 id := uint64(len(cases)) + 1
78 cases = append(cases, &Case{
79 ID: id,
80 Subject: subject,
81 Opener: cur.Previous().Address(),
82 Phase: PhaseCommit,
83 Jurors: panel,
84 OpenedAt: runtime.ChainHeight(),
85 })
86 chain.Emit("CaseOpened", "id", ufmt.Sprintf("%d", id), "jurors", ufmt.Sprintf("%d", len(panel)))
87 return id
88}
89
90// Commit locks in a juror's hidden verdict. commitment must equal
91// commit.Verdict(verdict, salt) — computed off-chain. When the last juror
92// commits, the case advances to the reveal phase automatically.
93func Commit(cur realm, caseID uint64, commitment string) {
94 c := mustCase(caseID)
95 assert(c.Phase == PhaseCommit, "not in commit phase")
96 assert(commitment != "", "empty commitment")
97 j := mustJuror(c, cur.Previous().Address())
98 assert(!j.Committed, "already committed")
99 j.Commitment = commitment
100 j.Committed = true
101 j.Height = runtime.ChainHeight()
102
103 if allCommitted(c) {
104 c.Phase = PhaseReveal
105 chain.Emit("CasePhase", "id", ufmt.Sprintf("%d", caseID), "phase", string(PhaseReveal))
106 }
107}
108
109// Reveal opens a juror's verdict. The (verdict, salt) must reproduce the
110// commitment made earlier, or the reveal is rejected. When the last juror
111// reveals, the case closes and the majority outcome is recorded.
112func Reveal(cur realm, caseID uint64, verdict bool, salt string) {
113 c := mustCase(caseID)
114 assert(c.Phase == PhaseReveal, "not in reveal phase")
115 j := mustJuror(c, cur.Previous().Address())
116 assert(j.Committed && !j.Revealed, "nothing to reveal")
117 assert(commit.Verdict(verdict, salt) == j.Commitment, "reveal does not match commitment")
118
119 j.Revealed = true
120 j.Verdict = verdict
121 if verdict {
122 c.Yes++
123 } else {
124 c.No++
125 }
126 chain.Emit("VerdictRevealed", "id", ufmt.Sprintf("%d", caseID), "verdict", boolStr(verdict))
127
128 if allRevealed(c) {
129 closeCase(c)
130 }
131}
132
133// ---- read-only API ----
134
135// Get returns a copy of a case (jurors flattened separately via Jury).
136func Get(caseID uint64) Case { return *mustCase(caseID) }
137
138// Outcome returns the recorded outcome, or "" if the case is still open.
139func Outcome(caseID uint64) string { return mustCase(caseID).Outcome }
140
141// Count returns the number of cases.
142func Count() int { return len(cases) }
143
144// ---- internal ----
145
146func closeCase(c *Case) {
147 switch {
148 case c.Yes > c.No:
149 c.Outcome = "upheld"
150 case c.No > c.Yes:
151 c.Outcome = "rejected"
152 default:
153 c.Outcome = "tie"
154 }
155 c.Phase = PhaseClosed
156 chain.Emit("CaseClosed", "id", ufmt.Sprintf("%d", c.ID), "outcome", c.Outcome)
157}
158
159func allCommitted(c *Case) bool {
160 for _, j := range c.Jurors {
161 if !j.Committed {
162 return false
163 }
164 }
165 return true
166}
167
168func allRevealed(c *Case) bool {
169 for _, j := range c.Jurors {
170 if !j.Revealed {
171 return false
172 }
173 }
174 return true
175}
176
177func mustCase(id uint64) *Case {
178 assert(id >= 1 && id <= uint64(len(cases)), "unknown case")
179 return cases[id-1]
180}
181
182func mustJuror(c *Case, addr address) *Juror {
183 for _, j := range c.Jurors {
184 if j.Addr == addr {
185 return j
186 }
187 }
188 panic("caller is not on this jury")
189}
190
191func boolStr(b bool) string {
192 if b {
193 return "yes"
194 }
195 return "no"
196}
197
198func assert(cond bool, msg string) {
199 if !cond {
200 panic(msg)
201 }
202}
203
204// Render shows all cases, or one case's panel + tally at :<id>.
205func Render(path string) string {
206 if path == "" {
207 return renderIndex()
208 }
209 return renderCase(parseID(path))
210}
211
212func renderIndex() string {
213 var sb strings.Builder
214 sb.WriteString("# Agent Jury\n\n")
215 sb.WriteString("_Who checks the agents?_ Blind commit-reveal review by a fixed panel.\n\n")
216 if len(cases) == 0 {
217 sb.WriteString("_No cases yet._\n")
218 return sb.String()
219 }
220 sb.WriteString("| # | Subject | Phase | ✅ | ❌ | Outcome |\n")
221 sb.WriteString("|---|---|---|---|---|---|\n")
222 for _, c := range cases {
223 out := c.Outcome
224 if out == "" {
225 out = "—"
226 }
227 sb.WriteString(ufmt.Sprintf("| [%d](/r/g1manfred47kzduec920z88wfr64ylksmdcedlf5/agents/jury:%d) | %s | %s | %d | %d | %s |\n",
228 c.ID, c.ID, c.Subject, string(c.Phase), c.Yes, c.No, out))
229 }
230 return sb.String()
231}
232
233func renderCase(id uint64) string {
234 c := mustCase(id)
235 var sb strings.Builder
236 sb.WriteString(ufmt.Sprintf("# Case #%d\n\n", c.ID))
237 sb.WriteString(ufmt.Sprintf("- **Subject:** %s\n", c.Subject))
238 sb.WriteString(ufmt.Sprintf("- **Phase:** %s\n", string(c.Phase)))
239 sb.WriteString(ufmt.Sprintf("- **Opened by:** %s at height %d\n", short(c.Opener), c.OpenedAt))
240 if c.Outcome != "" {
241 sb.WriteString(ufmt.Sprintf("- **Outcome:** **%s** (%d ✅ / %d ❌)\n", c.Outcome, c.Yes, c.No))
242 }
243 sb.WriteString("\n## Panel\n\n")
244 sb.WriteString("| Juror | Committed | Revealed | Verdict | Dissent |\n|---|---|---|---|---|\n")
245 majority := c.Yes >= c.No // for dissent marking once closed
246 for _, j := range c.Jurors {
247 verdict, dissent := "—", ""
248 if j.Revealed {
249 verdict = boolStr(j.Verdict)
250 if c.Phase == PhaseClosed && c.Outcome != "tie" && j.Verdict != majority {
251 dissent = "⚠"
252 }
253 }
254 sb.WriteString(ufmt.Sprintf("| %s | %t | %t | %s | %s |\n",
255 short(j.Addr), j.Committed, j.Revealed, verdict, dissent))
256 }
257 return sb.String()
258}
259
260func parseID(s string) uint64 {
261 var n uint64
262 for i := 0; i < len(s); i++ {
263 ch := s[i]
264 assert(ch >= '0' && ch <= '9', "invalid case path: "+s)
265 n = n*10 + uint64(ch-'0')
266 }
267 return n
268}
269
270func short(a address) string {
271 s := a.String()
272 if len(s) <= 12 {
273 return s
274 }
275 return s[:8] + "…" + s[len(s)-4:]
276}