feat(P4): Standing + Freeholder signals progress (REQ-043)

web/handlers/standing.go: GET /standing/{reachID} renders standing score
+ bucket + 4-signal progress + Freeholder-eligible badge. Store extensions:
ListRatings/ListVouches/ListSlashes/ComputeStandingScore (simplified, from
locked x/standing/types constants PriorMean/PriorWeight/ComputeDiversityBonus/
GetVoucherWeight/GetStandingBucket — NOT hardcoded)/ComputeFreeholderSignals
(real FreeholderSignals struct + IsFreeholderEligible). Seed: holder-alia
(12 ratings, 4 cats, 1 Vouch, mature Stash -> eligible) vs holder-bryn
(3 ratings, 1 cat, no Vouch, immature -> not eligible). standing_test.go:
computed-from-locked-constants regression guard (PriorMean=4.0, PriorWeight=
10, DiversityBonus4Cats=0.10), Freeholder-eligible badge reflects
IsFreeholderEligible() (the real method), G-026 error lexicon check.
Coverage: store 98.0%, handlers 88.7%.

---ci---
project: oy
phase: 4
milestone: v0.6
status: execute
---/ci---
This commit is contained in:
2026-08-18 19:02:44 +00:00
parent 50c84d0351
commit 451ea08414
7 changed files with 600 additions and 27 deletions
+74 -3
View File
@@ -4,6 +4,7 @@ import (
"time"
identitytypes "github.com/oy/openyield/x/identity/types"
standingtypes "github.com/oy/openyield/x/standing/types"
stashtypes "github.com/oy/openyield/x/stash/types"
)
@@ -11,13 +12,59 @@ import (
// list view. All strings lexicon-clean ("Holder"/"Reach"/"Stash"; NOT the
// banned financial terms). Two fixtures: one mature (90+ active days),
// one immature (45 active days) so the Stash dashboard (P2) can show both
// states.
// states. P4 seeds Ratings/Vouches so the Standing screen can show a
// Freeholder-eligible Reach (holder-alia) vs a non-eligible one (holder-bryn).
func (s *Store) seed() {
now := time.Now().Unix()
// Fixture 1: a mature Nomad (ActiveDays=92, MaxGapDays=10 -> IsMature()).
seedOne(s, "holder-alia", "pk-alia-001", now, 920000, 92, 10)
// Fixture 2: an immature Nomad (ActiveDays=45, MaxGapDays=5 -> not mature).
seedOne(s, "holder-bryn", "pk-bryn-002", now, 410000, 45, 5)
seedStanding(s, now)
}
// seedStanding seeds mock Ratings + Vouches. holder-alia gets 12 ratings
// across 4 categories at 4.6-4.9 (Freeholder-eligible: score >= 4.5 in >= 3
// cats) + 1 Vouch (CommunityEndorsement). holder-bryn gets 3 ratings in 1
// category (not eligible: < 3 categories, no Vouch).
func seedStanding(s *Store, now int64) {
// holder-alia: 12 ratings, 4 categories, scores 4.6-4.9.
aliaCats := []string{"care", "sim", "vault", "mail"}
for i := 0; i < 12; i++ {
cat := aliaCats[i%4]
score := 4.6 + float64(i%4)*0.1 // 4.6, 4.7, 4.8, 4.9 repeating
s.ratings["holder-alia"] = append(s.ratings["holder-alia"], standingtypes.Rating{
RaterID: "rater-" + itoa(i),
RateeID: "holder-alia",
Category: cat,
Score: score,
Weight: 1.0,
TxRef: "tx-r-" + itoa(i),
Timestamp: now - int64(i)*86400,
DecayBucket: 0, // 6mo bucket (1.0)
})
}
// 1 Vouch for holder-alia (CommunityEndorsement signal).
s.vouches["holder-alia"] = []standingtypes.Vouch{{
VoucherID: "voucher-freeholder-1",
VoucheeID: "holder-alia",
Category: "care",
BondAmount: 100000,
Timestamp: now,
}}
// holder-bryn: 3 ratings, 1 category, scores 4.0-4.2 (not eligible: < 3 cats).
for i := 0; i < 3; i++ {
s.ratings["holder-bryn"] = append(s.ratings["holder-bryn"], standingtypes.Rating{
RaterID: "rater-b-" + itoa(i),
RateeID: "holder-bryn",
Category: "care",
Score: 4.0 + float64(i)*0.1,
Weight: 1.0,
TxRef: "tx-b-" + itoa(i),
Timestamp: now - int64(i)*86400,
DecayBucket: 0,
})
}
// No Vouches for holder-bryn (CommunityEndorsement signal false).
}
func seedOne(s *Store, holderID, pubKey string, now int64, balanceGrain int64, activeDays, maxGap uint32) {
@@ -44,3 +91,27 @@ func seedOne(s *Store, holderID, pubKey string, now int64, balanceGrain int64, a
LastActivityDay: now,
}
}
// itoa is a tiny int->string helper to avoid importing strconv (keeps the
// fixtures file import-light; the mock data uses small integers only).
func itoa(n int) string {
if n == 0 {
return "0"
}
neg := n < 0
if neg {
n = -n
}
var buf [12]byte
i := len(buf)
for n > 0 {
i--
buf[i] = byte('0' + n%10)
n /= 10
}
if neg {
i--
buf[i] = '-'
}
return string(buf[i:])
}
+108
View File
@@ -14,6 +14,7 @@ import (
"time"
identitytypes "github.com/oy/openyield/x/identity/types"
standingtypes "github.com/oy/openyield/x/standing/types"
stashtypes "github.com/oy/openyield/x/stash/types"
windowtypes "github.com/oy/openyield/x/window/types"
)
@@ -30,6 +31,9 @@ type Store struct {
stashActivities map[string]stashtypes.StashActivity
windows map[string]windowtypes.Window
auditLogs map[string][]windowtypes.AuditEntry
ratings map[string][]standingtypes.Rating
vouches map[string][]standingtypes.Vouch
slashes map[string][]standingtypes.Slash
}
// NewStore constructs a Store seeded from fixtures (fixtures.go).
@@ -40,6 +44,9 @@ func NewStore() *Store {
stashActivities: map[string]stashtypes.StashActivity{},
windows: map[string]windowtypes.Window{},
auditLogs: map[string][]windowtypes.AuditEntry{},
ratings: map[string][]standingtypes.Rating{},
vouches: map[string][]standingtypes.Vouch{},
slashes: map[string][]standingtypes.Slash{},
}
s.seed()
return s
@@ -291,3 +298,104 @@ func validateReachInput(holderID, publicKey string) error {
}
return nil
}
// --- Standing + Freeholder signals (P4) ---
// ListRatings returns all Ratings for a ratee (per-Reach).
func (s *Store) ListRatings(rateeID string) []standingtypes.Rating {
s.mu.Lock()
defer s.mu.Unlock()
return s.ratings[rateeID]
}
// ListVouches returns all Vouches for a vouchee.
func (s *Store) ListVouches(voucheeID string) []standingtypes.Vouch {
s.mu.Lock()
defer s.mu.Unlock()
return s.vouches[voucheeID]
}
// ListSlashes returns all Slashes for a Reach.
func (s *Store) ListSlashes(reachID string) []standingtypes.Slash {
s.mu.Lock()
defer s.mu.Unlock()
return s.slashes[reachID]
}
// ComputeStandingScore computes a simplified standing score from the mock
// Ratings using the locked x/standing/types constants (PriorMean, PriorWeight,
// ComputeDiversityBonus, GetVoucherWeight, GetStandingBucket). This is a
// SIMPLIFIED computation (not the full Bayesian formula — sub-tables deferred
// per PROJECT.md Q2); the test asserts it uses the locked constants, not that
// it matches a full oracle.
func (s *Store) ComputeStandingScore(reachID string) (float64, standingtypes.StandingBucket) {
s.mu.Lock()
defer s.mu.Unlock()
ratings := s.ratings[reachID]
slashes := s.slashes[reachID]
isSlashed := len(slashes) > 0
if len(ratings) == 0 {
// No ratings: return the prior mean, bucket New.
return standingtypes.PriorMean, standingtypes.GetStandingBucket(standingtypes.PriorMean, 0, isSlashed)
}
// Simplified: weighted average of rating scores using GetVoucherWeight.
// The real formula uses the rater's standing to derive the weight; the
// mock uses the ratee's own score iteratively (simplified — P4 does not
// build a full rater-graph). Uses the locked PriorMean + PriorWeight as a
// Bayesian shrinkage: score = (prior*weight + sum(scores)) / (weight + n).
sum := 0.0
categories := map[string]bool{}
for _, r := range ratings {
w := standingtypes.GetVoucherWeight(false, r.Score, len(ratings))
sum += r.Score * w
categories[r.Category] = true
}
n := float64(len(ratings))
raw := (standingtypes.PriorMean*float64(standingtypes.PriorWeight) + sum) /
(float64(standingtypes.PriorWeight) + n)
// Apply diversity bonus (locked const).
raw += standingtypes.ComputeDiversityBonus(len(categories))
bucket := standingtypes.GetStandingBucket(raw, len(ratings), isSlashed)
return raw, bucket
}
// ComputeFreeholderSignals computes the four Freeholder signals (§9.1) from
// the mock data. StashMaturity from StashActivity.IsMature(); MultiDomainStanding
// from score >= FreeholderMinStandingScore in >= FreeholderMinCategories;
// CommittedCapital from Stash balance >= a threshold (mock); CommunityEndorsement
// from >= 1 Vouch. Returns the real standingtypes.FreeholderSignals struct.
func (s *Store) ComputeFreeholderSignals(reachID string) standingtypes.FreeholderSignals {
s.mu.Lock()
stash, hasStash := s.stashes[reachID]
ratings := s.ratings[reachID]
vouches := s.vouches[reachID]
s.mu.Unlock()
var signals standingtypes.FreeholderSignals
// StashMaturity: from StashActivity.IsMature() (the real method).
if hasStash {
if activity, ok := s.GetStashActivity(stash.StashID); ok {
signals.StashMaturity = activity.IsMature()
}
}
// MultiDomainStanding: score >= 4.5 in >= 3 categories.
score, _ := s.ComputeStandingScore(reachID)
categories := map[string]bool{}
for _, r := range ratings {
categories[r.Category] = true
}
if score >= standingtypes.FreeholderMinStandingScore && len(categories) >= standingtypes.FreeholderMinCategories {
signals.MultiDomainStanding = true
}
// CommittedCapital: mock threshold — Stash balance >= 100000 Grain (10 Bread).
if hasStash && stash.BalanceGrain >= 100000 {
signals.CommittedCapital = true
}
// CommunityEndorsement: >= 1 Vouch.
if len(vouches) >= 1 {
signals.CommunityEndorsement = true
}
return signals
}
+119 -23
View File
@@ -5,6 +5,7 @@ import (
"testing"
identitytypes "github.com/oy/openyield/x/identity/types"
standingtypes "github.com/oy/openyield/x/standing/types"
stashtypes "github.com/oy/openyield/x/stash/types"
windowtypes "github.com/oy/openyield/x/window/types"
)
@@ -184,29 +185,7 @@ func TestSeededMatureVsImmature(t *testing.T) {
var _ identitytypes.Reach
var _ stashtypes.Stash
// itoa is a tiny strconv.Itoa without the import (keeps store_test.go deps
// to just sync + testing + the two x/*/types packages).
func itoa(n int) string {
if n == 0 {
return "0"
}
neg := n < 0
if neg {
n = -n
}
var buf [20]byte
i := len(buf)
for n > 0 {
i--
buf[i] = byte('0' + n%10)
n /= 10
}
if neg {
i--
buf[i] = '-'
}
return string(buf[i:])
}
// itoa is provided by fixtures.go (shared with the production package).
func stringOf(r rune, n int) string {
b := make([]byte, n)
@@ -434,3 +413,120 @@ func TestGetAuditLogEmptyForMissing(t *testing.T) {
t.Errorf("GetAuditLog(nobody) = %v, want nil", logs)
}
}
// --- Standing + Freeholder signals tests (P4) ---
func TestListRatingsSeeded(t *testing.T) {
s := NewStore()
alia := s.ListRatings("holder-alia")
if len(alia) != 12 {
t.Errorf("ListRatings(holder-alia) = %d, want 12 (seeded)", len(alia))
}
bryn := s.ListRatings("holder-bryn")
if len(bryn) != 3 {
t.Errorf("ListRatings(holder-bryn) = %d, want 3 (seeded)", len(bryn))
}
nobody := s.ListRatings("nobody")
if len(nobody) != 0 {
t.Errorf("ListRatings(nobody) = %d, want 0", len(nobody))
}
}
func TestListVouchesSeeded(t *testing.T) {
s := NewStore()
alia := s.ListVouches("holder-alia")
if len(alia) != 1 {
t.Errorf("ListVouches(holder-alia) = %d, want 1 (seeded)", len(alia))
}
bryn := s.ListVouches("holder-bryn")
if len(bryn) != 0 {
t.Errorf("ListVouches(holder-bryn) = %d, want 0 (seeded)", len(bryn))
}
}
func TestListSlashesEmptyByDefault(t *testing.T) {
s := NewStore()
if sl := s.ListSlashes("holder-alia"); len(sl) != 0 {
t.Errorf("ListSlashes(holder-alia) = %d, want 0 (no slashes seeded)", len(sl))
}
}
func TestComputeStandingScoreNoRatingsReturnsPriorMean(t *testing.T) {
s := NewStore()
score, bucket := s.ComputeStandingScore("nobody")
if score != standingtypes.PriorMean {
t.Errorf("ComputeStandingScore(nobody) score = %v, want PriorMean %v", score, standingtypes.PriorMean)
}
if bucket != standingtypes.BucketNew {
t.Errorf("ComputeStandingScore(nobody) bucket = %q, want New", bucket)
}
}
func TestComputeStandingScoreAliaIsEligibleRange(t *testing.T) {
s := NewStore()
score, bucket := s.ComputeStandingScore("holder-alia")
if score < 4.5 {
t.Errorf("holder-alia score = %.2f, want >= 4.5 (Freeholder-eligible range)", score)
}
if bucket != standingtypes.BucketPreferred && bucket != standingtypes.BucketTop {
t.Errorf("holder-alia bucket = %q, want Preferred or Top", bucket)
}
}
func TestComputeStandingScoreBrynIsNew(t *testing.T) {
s := NewStore()
_, bucket := s.ComputeStandingScore("holder-bryn")
// holder-bryn has 3 ratings (< 10) -> bucket New.
if bucket != standingtypes.BucketNew {
t.Errorf("holder-bryn bucket = %q, want New (< 10 ratings)", bucket)
}
}
func TestComputeFreeholderSignalsAliaAllTrue(t *testing.T) {
s := NewStore()
signals := s.ComputeFreeholderSignals("holder-alia")
// holder-alia: mature Stash (92 days), score >= 4.5 in 4 cats, balance
// 920000 >= 100000, 1 Vouch -> all 4 signals true.
if !signals.StashMaturity {
t.Errorf("StashMaturity = false, want true (mature Stash)")
}
if !signals.MultiDomainStanding {
t.Errorf("MultiDomainStanding = false, want true (score >= 4.5 in 4 cats)")
}
if !signals.CommittedCapital {
t.Errorf("CommittedCapital = false, want true (balance 920000 >= 100000)")
}
if !signals.CommunityEndorsement {
t.Errorf("CommunityEndorsement = false, want true (1 Vouch seeded)")
}
if !signals.IsFreeholderEligible() {
t.Errorf("holder-alia IsFreeholderEligible = false, want true (all 4 signals)")
}
}
func TestComputeFreeholderSignalsBrynNotEligible(t *testing.T) {
s := NewStore()
signals := s.ComputeFreeholderSignals("holder-bryn")
// holder-bryn: immature Stash (45 days), 1 cat (< 3), no Vouch.
if signals.StashMaturity {
t.Errorf("StashMaturity = true, want false (immature 45 days)")
}
if signals.MultiDomainStanding {
t.Errorf("MultiDomainStanding = true, want false (1 cat < 3)")
}
if signals.CommunityEndorsement {
t.Errorf("CommunityEndorsement = true, want false (no Vouches)")
}
if signals.IsFreeholderEligible() {
t.Errorf("holder-bryn IsFreeholderEligible = true, want false")
}
}
func TestComputeFreeholderSignalsNoStash(t *testing.T) {
s := NewStore()
signals := s.ComputeFreeholderSignals("nobody")
// No Stash, no ratings, no Vouches -> all false.
if signals.IsFreeholderEligible() {
t.Errorf("nobody IsFreeholderEligible = true, want false (no Stash)")
}
}