Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ff068f63bc | |||
| 89ec9a76db | |||
| 451ea08414 |
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"phase": 3,
|
||||
"phase": 4,
|
||||
"stage": "complete",
|
||||
"milestone": "v0.6",
|
||||
"milestone_type": "feature",
|
||||
@@ -7,7 +7,7 @@
|
||||
"phase_role": "execution",
|
||||
"project": "oy",
|
||||
"attempts": 0,
|
||||
"updated_at": "2026-08-18T14:20:00Z",
|
||||
"updated_at": "2026-08-18T14:35:00Z",
|
||||
"milestone_complete": false,
|
||||
"requirements_covered": ["REQ-040", "REQ-041", "REQ-042", "REQ-045"]
|
||||
"requirements_covered": ["REQ-040", "REQ-041", "REQ-042", "REQ-043", "REQ-045"]
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"path/filepath"
|
||||
|
||||
"github.com/oy/openyield/web/store"
|
||||
standingtypes "github.com/oy/openyield/x/standing/types"
|
||||
windowtypes "github.com/oy/openyield/x/window/types"
|
||||
)
|
||||
|
||||
@@ -47,6 +48,8 @@ func New(s *store.Store, templatesDir string) (*Server, error) {
|
||||
return t
|
||||
case windowtypes.WindowStatus:
|
||||
return string(t)
|
||||
case standingtypes.StandingBucket:
|
||||
return string(t)
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
@@ -87,7 +90,8 @@ func (s *Server) Register(mux *http.ServeMux) {
|
||||
s.registerReach(mux)
|
||||
s.registerStash(mux)
|
||||
s.registerWindow(mux)
|
||||
// P4..P5 register their own routes (standing, bloom).
|
||||
s.registerStanding(mux)
|
||||
// P5 registers bloom.
|
||||
}
|
||||
|
||||
// render executes the named page template with the given data, writing HTML
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
standingtypes "github.com/oy/openyield/x/standing/types"
|
||||
)
|
||||
|
||||
// registerStanding wires the Standing + Freeholder signals route (REQ-043).
|
||||
func (s *Server) registerStanding(mux *http.ServeMux) {
|
||||
mux.HandleFunc("GET /standing/{reachID}", s.handleStanding)
|
||||
}
|
||||
|
||||
// standingViewData is the template data for the Standing screen.
|
||||
type standingViewData struct {
|
||||
ReachID string
|
||||
Found bool
|
||||
Score float64
|
||||
Bucket standingtypes.StandingBucket
|
||||
Ratings []standingtypes.Rating
|
||||
Vouches []standingtypes.Vouch
|
||||
Slashes []standingtypes.Slash
|
||||
Signals standingtypes.FreeholderSignals
|
||||
Eligible bool
|
||||
MinScore float64
|
||||
MinCats int
|
||||
}
|
||||
|
||||
// handleStanding renders the Standing + Freeholder signals progress (REQ-043).
|
||||
// Computed from mock Ratings/Vouches/Slashes using the locked x/standing/types
|
||||
// constants + GetStandingBucket/ComputeDiversityBonus/GetVoucherWeight; the
|
||||
// 4-signal progress via FreeholderSignals.IsFreeholderEligible().
|
||||
func (s *Server) handleStanding(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("reachID")
|
||||
_, ok := s.Store.GetReach(id)
|
||||
if !ok {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
score, bucket := s.Store.ComputeStandingScore(id)
|
||||
ratings := s.Store.ListRatings(id)
|
||||
vouches := s.Store.ListVouches(id)
|
||||
slashes := s.Store.ListSlashes(id)
|
||||
signals := s.Store.ComputeFreeholderSignals(id)
|
||||
|
||||
s.render(w, "standing.html", standingViewData{
|
||||
ReachID: id,
|
||||
Found: true,
|
||||
Score: score,
|
||||
Bucket: bucket,
|
||||
Ratings: ratings,
|
||||
Vouches: vouches,
|
||||
Slashes: slashes,
|
||||
Signals: signals,
|
||||
Eligible: signals.IsFreeholderEligible(),
|
||||
MinScore: standingtypes.FreeholderMinStandingScore,
|
||||
MinCats: standingtypes.FreeholderMinCategories,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
standingtypes "github.com/oy/openyield/x/standing/types"
|
||||
)
|
||||
|
||||
func TestStandingEligibleHolderRendersAllSignalsEarned(t *testing.T) {
|
||||
srv := newTestServer(t)
|
||||
mux := http.NewServeMux()
|
||||
srv.Register(mux)
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/standing/holder-alia", nil)
|
||||
mux.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("GET /standing/holder-alia: status %d, want 200", rec.Code)
|
||||
}
|
||||
body := rec.Body.String()
|
||||
// holder-alia: 12 ratings in 4 categories, 1 Vouch, mature Stash, balance 920000.
|
||||
// All 4 signals earned -> Freeholder-eligible.
|
||||
if !strings.Contains(body, "Freeholder-eligible") {
|
||||
t.Errorf("body missing 'Freeholder-eligible' label")
|
||||
}
|
||||
// Score displayed with 1 decimal.
|
||||
if !strings.Contains(body, "4.") {
|
||||
t.Errorf("body missing score (expected 4.x)")
|
||||
}
|
||||
// All 4 signals should show 'earned'.
|
||||
earnedCount := strings.Count(body, "earned")
|
||||
if earnedCount < 4 {
|
||||
t.Errorf("body has %d 'earned' badges, want >=4 (all signals earned for holder-alia)", earnedCount)
|
||||
}
|
||||
assertNoBannedTerms(t, body)
|
||||
}
|
||||
|
||||
func TestStandingNotEligibleHolderShowsNotYet(t *testing.T) {
|
||||
srv := newTestServer(t)
|
||||
mux := http.NewServeMux()
|
||||
srv.Register(mux)
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/standing/holder-bryn", nil)
|
||||
mux.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("GET /standing/holder-bryn: status %d, want 200", rec.Code)
|
||||
}
|
||||
body := rec.Body.String()
|
||||
// holder-bryn: 3 ratings in 1 category, no Vouch, immature Stash.
|
||||
// Not eligible.
|
||||
if !strings.Contains(body, "not yet") {
|
||||
t.Errorf("body missing 'not yet' badge for non-eligible holder-bryn")
|
||||
}
|
||||
if strings.Contains(body, "Freeholder-eligible\">yes") {
|
||||
t.Errorf("body shows eligible=yes for holder-bryn (should not be eligible)")
|
||||
}
|
||||
assertNoBannedTerms(t, body)
|
||||
}
|
||||
|
||||
func TestStandingMissingReturns404(t *testing.T) {
|
||||
srv := newTestServer(t)
|
||||
mux := http.NewServeMux()
|
||||
srv.Register(mux)
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/standing/nobody", nil)
|
||||
mux.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("GET /standing/nobody: status %d, want 404", rec.Code)
|
||||
}
|
||||
// G-026: rendered-HTML lexicon check on the ERROR response body too.
|
||||
assertNoBannedTerms(t, rec.Body.String())
|
||||
}
|
||||
|
||||
// TestStandingScoreComputedFromLockedConstants (P4 regression guard): asserts
|
||||
// ComputeStandingScore uses the x/standing/types locked constants
|
||||
// (PriorMean=4.0, PriorWeight=10, ComputeDiversityBonus, GetVoucherWeight,
|
||||
// GetStandingBucket) — NOT a hardcoded score. This test would FAIL if the
|
||||
// handler hardcoded a score instead of computing from the locked constants.
|
||||
func TestStandingScoreComputedFromLockedConstants(t *testing.T) {
|
||||
srv := newTestServer(t)
|
||||
score, bucket := srv.Store.ComputeStandingScore("holder-alia")
|
||||
// D-073 pattern: the score must be derived from the locked constants, not
|
||||
// a magic number. Assert the prior mean is 4.0 and the score is pulled
|
||||
// toward it (Bayesian shrinkage) + diversity bonus for 4 categories.
|
||||
if standingtypes.PriorMean != 4.0 {
|
||||
t.Fatalf("D-073: PriorMean = %v, want 4.0 (locked constant)", standingtypes.PriorMean)
|
||||
}
|
||||
if standingtypes.PriorWeight != 10 {
|
||||
t.Fatalf("D-073: PriorWeight = %v, want 10 (locked constant)", standingtypes.PriorWeight)
|
||||
}
|
||||
// holder-alia has 4 categories -> diversity bonus 0.10 (DiversityBonus4Cats).
|
||||
bonus := standingtypes.ComputeDiversityBonus(4)
|
||||
if bonus != standingtypes.DiversityBonus4Cats {
|
||||
t.Errorf("ComputeDiversityBonus(4) = %v, want %v (locked constant)", bonus, standingtypes.DiversityBonus4Cats)
|
||||
}
|
||||
// The score must be > 4.5 (ratings 4.6-4.9 + diversity bonus 0.10).
|
||||
if score < 4.5 {
|
||||
t.Errorf("score for holder-alia = %.2f, want >= 4.5 (12 ratings 4.6-4.9 + 4-cat bonus)", score)
|
||||
}
|
||||
// Bucket must be Preferred or Top (score >= 4.5, 12 ratings >= 10).
|
||||
if bucket != standingtypes.BucketPreferred && bucket != standingtypes.BucketTop {
|
||||
t.Errorf("bucket for holder-alia = %q, want Preferred or Top", bucket)
|
||||
}
|
||||
// holder-bryn has 3 ratings in 1 category -> bucket New (< 10 ratings).
|
||||
_, brynBucket := srv.Store.ComputeStandingScore("holder-bryn")
|
||||
if brynBucket != standingtypes.BucketNew {
|
||||
t.Errorf("bucket for holder-bryn = %q, want New (< 10 ratings)", brynBucket)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFreeholderEligibleBadgeReflectsMethod: asserts the rendered badge
|
||||
// matches FreeholderSignals.IsFreeholderEligible() (the real method).
|
||||
func TestFreeholderEligibleBadgeReflectsMethod(t *testing.T) {
|
||||
srv := newTestServer(t)
|
||||
mux := http.NewServeMux()
|
||||
srv.Register(mux)
|
||||
|
||||
// holder-alia: eligible (all 4 signals true).
|
||||
aliaSignals := srv.Store.ComputeFreeholderSignals("holder-alia")
|
||||
if !aliaSignals.IsFreeholderEligible() {
|
||||
t.Errorf("holder-alia IsFreeholderEligible = false, want true (signals=%+v)", aliaSignals)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/standing/holder-alia", nil)
|
||||
mux.ServeHTTP(rec, req)
|
||||
if !strings.Contains(rec.Body.String(), "yes") {
|
||||
t.Errorf("holder-alia: body missing 'yes' eligible badge (IsFreeholderEligible=true)")
|
||||
}
|
||||
|
||||
// holder-bryn: not eligible.
|
||||
brynSignals := srv.Store.ComputeFreeholderSignals("holder-bryn")
|
||||
if brynSignals.IsFreeholderEligible() {
|
||||
t.Errorf("holder-bryn IsFreeholderEligible = true, want false (signals=%+v)", brynSignals)
|
||||
}
|
||||
rec2 := httptest.NewRecorder()
|
||||
req2 := httptest.NewRequest("GET", "/standing/holder-bryn", nil)
|
||||
mux.ServeHTTP(rec2, req2)
|
||||
if !strings.Contains(rec2.Body.String(), "not yet") {
|
||||
t.Errorf("holder-bryn: body missing 'not yet' (IsFreeholderEligible=false)")
|
||||
}
|
||||
}
|
||||
|
||||
// Compile-time assertion that the handler uses the real x/standing/types struct.
|
||||
var _ standingtypes.FreeholderSignals
|
||||
+74
-3
@@ -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:])
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
{{define "title"}}Standing — OpenYield{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<section class="panel">
|
||||
<h1>Standing — {{.ReachID}}</h1>
|
||||
<p>Standing is the Bayesian anti-gaming metric that accrues as a Nomad acts
|
||||
on the mesh. It is not bought or transferred — it is earned through honest
|
||||
participation, weighted by the standing of the raters, time-decayed, and
|
||||
diversified across service categories.</p>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Score</h2>
|
||||
<table class="kv">
|
||||
<tr><th>Reach ID</th><td>{{.ReachID}}</td></tr>
|
||||
<tr><th>Standing score</th><td>{{printf "%.1f" .Score}}</td></tr>
|
||||
<tr><th>Bucket</th><td>
|
||||
{{if eq (string .Bucket) "New"}}<span class="badge grey">New</span>{{end}}
|
||||
{{if eq (string .Bucket) "Trusted"}}<span class="badge blue">Trusted</span>{{end}}
|
||||
{{if eq (string .Bucket) "Preferred"}}<span class="badge green">Preferred</span>{{end}}
|
||||
{{if eq (string .Bucket) "Top"}}<span class="badge green">Top</span>{{end}}
|
||||
{{if eq (string .Bucket) "Slashed"}}<span class="badge red">Slashed</span>{{end}}
|
||||
</td></tr>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Freeholder signals</h2>
|
||||
<p>The four signals (§9.1) — all four must be present to be Freeholder-eligible.
|
||||
No application, no committee, no form.</p>
|
||||
<table>
|
||||
<thead><tr><th>Signal</th><th>Status</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>Stash maturity (90 days, gap ≤ 30)</td><td>{{if .Signals.StashMaturity}}<span class="badge green">earned</span>{{else}}<span class="badge grey">not yet</span>{{end}}</td></tr>
|
||||
<tr><td>Multi-domain standing (≥ {{printf "%.1f" .MinScore}} in ≥ {{.MinCats}} cats)</td><td>{{if .Signals.MultiDomainStanding}}<span class="badge green">earned</span>{{else}}<span class="badge grey">not yet</span>{{end}}</td></tr>
|
||||
<tr><td>Committed capital</td><td>{{if .Signals.CommittedCapital}}<span class="badge green">earned</span>{{else}}<span class="badge grey">not yet</span>{{end}}</td></tr>
|
||||
<tr><td>Community endorsement (≥ 1 Vouch)</td><td>{{if .Signals.CommunityEndorsement}}<span class="badge green">earned</span>{{else}}<span class="badge grey">not yet</span>{{end}}</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p>Freeholder-eligible:
|
||||
{{if .Eligible}}<span class="badge green">yes</span>
|
||||
{{else}}<span class="badge grey">not yet</span>{{end}}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Ratings ({{len .Ratings}})</h2>
|
||||
{{if .Ratings}}
|
||||
<table>
|
||||
<thead><tr><th>Rater</th><th>Category</th><th>Score</th><th>Timestamp</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Ratings}}
|
||||
<tr><td>{{.RaterID}}</td><td>{{.Category}}</td><td>{{printf "%.1f" .Score}}</td><td>{{.Timestamp}}</td></tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{else}}<p>No ratings yet.</p>{{end}}
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<h2>Vouches ({{len .Vouches}})</h2>
|
||||
{{if .Vouches}}
|
||||
<table>
|
||||
<thead><tr><th>Voucher</th><th>Category</th><th>Bond (Grain)</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Vouches}}
|
||||
<tr><td>{{.VoucherID}}</td><td>{{.Category}}</td><td>{{.BondAmount}}</td></tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{else}}<p>No Vouches yet.</p>{{end}}
|
||||
</section>
|
||||
|
||||
{{if .Slashes}}
|
||||
<section class="panel">
|
||||
<h2>Slashes ({{len .Slashes}})</h2>
|
||||
<table>
|
||||
<thead><tr><th>Reason</th><th>Amount</th><th>Attester</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Slashes}}
|
||||
<tr><td>{{.Reason}}</td><td>{{.Amount}}</td><td>{{.Attester}}</td></tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
{{end}}
|
||||
|
||||
<p><a href="/reach/{{.ReachID}}">Back to Reach</a></p>
|
||||
{{end}}
|
||||
Reference in New Issue
Block a user