package evolve import ( "context" "errors" "fmt" "log" ) // ProposerFunc maps KPI evidence to a proposed parameter value. // // The caller supplies the mapping because evidence→value shape is highly // domain specific: logistics might use a weighted moving average of on_time // rate, finance might use an EMA of realised P&L, ad-tech might use a PID // controller on conversion lift. Forcing a single statistical flavour on the // engine layer would limit consumers; forcing a taxonomy (EMA / PID / mean) // would bloat the surface area without covering all real cases. // // Confidence is a 0..1 scalar the caller attaches to the proposal so the // downstream gate (human approval / ShadowRunner / batch approver) can // threshold-filter weak suggestions. type ProposerFunc func(ctx context.Context, key string, evidence []Feedback) (any, float64, error) // DefaultParameterEvolver is the reference ParameterEvolver. // // Propose is a thin delegate to ProposerFunc. Apply is the gatekeeper: // approved=true routes the value into ParameterStore.Set; approved=false is // a no-op on the store (rejected proposals must not pollute the version // chain) but both branches emit an audit log line so an operator can see // every decision that was offered, regardless of outcome. // // Relation to two-phase design: // The interface split Propose/Apply exists to insert gates between them // (human approval, ShadowRunner compare, batch approval). This impl keeps // that contract intact -- there is no side channel from Propose to Apply. // The caller is expected to call Apply once they have decided. type DefaultParameterEvolver struct { store ParameterStore proposer ProposerFunc logger func(format string, args ...any) } // EvolverOption configures a DefaultParameterEvolver. type EvolverOption func(*DefaultParameterEvolver) // WithAuditLogger routes every Apply call (approved AND rejected) through fn // so consumers can wire an AuditSink (DB / Loki / SIEM). Default: log.Printf. // Passing nil is a no-op: silent audit would be a dangerous footgun for a // governance-critical operation, so the default stays in place. // // Named WithAuditLogger rather than WithLogger because WithLogger is already // taken by DefaultLogReplayer in the same package. "Audit" is also the more // accurate semantic for this sink: Apply decisions must be auditable. func WithAuditLogger(fn func(format string, args ...any)) EvolverOption { return func(e *DefaultParameterEvolver) { if fn != nil { e.logger = fn } } } // NewDefaultParameterEvolver constructs the evolver. Both store and proposer // are required; either nil is a programming error and rejected at // construction rather than deferred to first call. func NewDefaultParameterEvolver(store ParameterStore, proposer ProposerFunc, opts ...EvolverOption) (*DefaultParameterEvolver, error) { if store == nil { return nil, errors.New("evolve: NewDefaultParameterEvolver requires non-nil ParameterStore") } if proposer == nil { return nil, errors.New("evolve: NewDefaultParameterEvolver requires non-nil ProposerFunc") } e := &DefaultParameterEvolver{ store: store, proposer: proposer, logger: log.Printf, } for _, o := range opts { o(e) } return e, nil } // Propose implements ParameterEvolver. Delegates to the injected proposer. // Does not read or write ParameterStore: the interface contract says Propose // is read-only relative to storage. func (e *DefaultParameterEvolver) Propose(ctx context.Context, key string, evidence []Feedback) (any, float64, error) { if err := ctx.Err(); err != nil { return nil, 0, err } value, confidence, err := e.proposer(ctx, key, evidence) if err != nil { return nil, 0, fmt.Errorf("evolve: propose %q: %w", key, err) } return value, confidence, nil } // Apply implements ParameterEvolver. // // Semantics: // - approved=true: delegates to ParameterStore.Set. Empty reason surfaces // the store's ErrReasonRequired verbatim (we do not fabricate a reason // on behalf of the caller -- audit trails must reflect human intent). // - approved=false: no-op on the store. An operator rejected the proposal; // the version chain stays clean. // // Both branches emit an audit log line so the decision itself is observable // even when the store is not touched. func (e *DefaultParameterEvolver) Apply(ctx context.Context, key string, value any, approved bool, reason string) error { if err := ctx.Err(); err != nil { return err } if !approved { // CLEVER: reject is logged even when reason is empty. A silent drop // would make it impossible to audit "we considered this and said no". e.logger("evolve: Apply rejected key=%q value=%v reason=%q", key, value, reason) return nil } version, err := e.store.Set(ctx, key, value, reason) if err != nil { e.logger("evolve: Apply failed key=%q value=%v reason=%q err=%v", key, value, reason, err) return fmt.Errorf("evolve: apply %q: %w", key, err) } e.logger("evolve: Apply accepted key=%q value=%v reason=%q version=%d", key, value, reason, version) return nil }