fluid-core/internal/science/competition.go
tegwick 634807a0cb Add hypothesis lifecycle, competition groups, experiments and promotion
FLUID-WP-0006 T01, T02, T04, T07. The lifecycle graph is explicit
because the states carry meaning a reader relies on: a hypothesis that
jumped from DRAFT to ACCEPTED would claim evidence it never gathered and
the audit trail would show nothing wrong. Drafting stays cheap and
completeness is checked at READY, which is the claim that an idea is
worth someone's time.

Competition membership is symmetric, so a reader looking at one
hypothesis cannot miss that a rival exists. Losing a competition
supersedes rather than rejects: rejection says the explanation was
wrong, superseded says a better one won, and the distinction matters
when the winner is later refuted.

The experiment controller never touches traffic. Start returns a routing
policy for the router to consume and Stop returns one with no rules, so
interrupting an experiment is a document replacement rather than an
unwind. Allocation must sum to one, or matching traffic would fall
through to the default and quietly contaminate the control arm.

Promotion consults the gate and never bypasses it. A human may promote
against an inconclusive verdict, but only with an acknowledged override
that is recorded as one; outcomes that reduce exposure need no gate at
all, since requiring permission to stop would point a safety property
the wrong way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014KmVxhJ35tCo7rE7UnLwWu

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 1116572@bnt-lap001
Assistant-Session: 8ba9bb93-a72a-4883-b189-2499cce5c400
2026-09-04 06:39:33 +02:00

158 lines
4.7 KiB
Go

package science
import (
"context"
"errors"
"fmt"
"sort"
"github.com/tegwick/fluid-core/internal/contract"
)
// Competition groups rival explanations of the same pressure.
//
// FluidAPIStandards.md section 19 requires this and section 65 of the same
// document asks the Daimon to preserve uncertainty where evidence is
// insufficient. A framework that forced one explanation forward would make
// premature commitment the default and hide the alternatives that a later
// reader might have preferred.
type Competition struct {
GroupID string `json:"group_id"`
Members []contract.HypothesisID `json:"members"`
Resolved bool `json:"resolved"`
Preferred contract.HypothesisID `json:"preferred,omitempty"`
}
// ErrNotCompeting reports hypotheses that are not in the same group.
var ErrNotCompeting = errors.New("hypotheses are not in the same competition group")
// Compete places hypotheses into a competition group.
//
// Membership is symmetric: every member lists every other. A one-way link
// would let a reader looking at one hypothesis miss that a rival exists, which
// is precisely the mistake the group is meant to prevent.
func (s *HypothesisStore) Compete(ctx context.Context, groupID string, ids []contract.HypothesisID, actor contract.Actor) (Competition, error) {
if groupID == "" {
return Competition{}, errors.New("a competition group needs an id")
}
if len(ids) < 2 {
return Competition{}, errors.New("a competition group needs at least two hypotheses")
}
seen := map[contract.HypothesisID]struct{}{}
members := make([]contract.HypothesisID, 0, len(ids))
for _, id := range ids {
if _, dup := seen[id]; dup {
continue
}
if _, err := s.Get(ctx, id); err != nil {
return Competition{}, fmt.Errorf("cannot add %s to %s: %w", id, groupID, err)
}
seen[id] = struct{}{}
members = append(members, id)
}
sort.Slice(members, func(i, j int) bool { return members[i] < members[j] })
for _, id := range members {
h, err := s.Get(ctx, id)
if err != nil {
return Competition{}, err
}
alternatives := make([]contract.HypothesisID, 0, len(members)-1)
for _, other := range members {
if other != id {
alternatives = append(alternatives, other)
}
}
h.Competition = &contract.FluidHypothesisCompetition{
GroupID: groupID,
Alternatives: alternatives,
}
if err := s.put(ctx, h); err != nil {
return Competition{}, err
}
if err := s.event(ctx, h, "HYPOTHESIS_COMPETING", actor,
fmt.Sprintf("joined competition group %s against %v", groupID, alternatives), nil); err != nil {
return Competition{}, err
}
}
return Competition{GroupID: groupID, Members: members}, nil
}
// Group returns the members of a competition group.
func (s *HypothesisStore) Group(ctx context.Context, groupID string) (Competition, error) {
all, err := s.List(ctx, "")
if err != nil {
return Competition{}, err
}
c := Competition{GroupID: groupID}
for _, h := range all {
if h.Competition == nil || h.Competition.GroupID != groupID {
continue
}
c.Members = append(c.Members, h.ID)
if h.State == contract.FluidHypothesisStateACCEPTED {
c.Resolved = true
c.Preferred = h.ID
}
}
sort.Slice(c.Members, func(i, j int) bool { return c.Members[i] < c.Members[j] })
if len(c.Members) == 0 {
return c, fmt.Errorf("no hypotheses in competition group %q", groupID)
}
return c, nil
}
// Resolve accepts one member of a competition and supersedes the rest.
//
// The losers become SUPERSEDED rather than REJECTED. Rejection says the
// explanation was wrong; superseded says a better one won. The distinction
// matters when the winner is later refuted and someone goes looking for what
// else had been considered.
func (s *HypothesisStore) Resolve(ctx context.Context, groupID string, winner contract.HypothesisID, actor contract.Actor, reason string) (Competition, error) {
if reason == "" {
return Competition{}, errors.New("resolving a competition requires a reason")
}
group, err := s.Group(ctx, groupID)
if err != nil {
return group, err
}
found := false
for _, id := range group.Members {
if id == winner {
found = true
}
}
if !found {
return group, fmt.Errorf("%w: %s is not in %s", ErrNotCompeting, winner, groupID)
}
for _, id := range group.Members {
if id == winner {
continue
}
h, err := s.Get(ctx, id)
if err != nil {
return group, err
}
if h.State == contract.FluidHypothesisStateSUPERSEDED ||
h.State == contract.FluidHypothesisStateREJECTED {
continue
}
if _, err := s.Transition(ctx, id, contract.FluidHypothesisStateSUPERSEDED, actor,
fmt.Sprintf("superseded by %s in competition %s: %s", winner, groupID, reason)); err != nil {
return group, err
}
}
group.Resolved = true
group.Preferred = winner
return group, nil
}