package engine import "sync" // Event is a non-fatal observation made during integration. // // Events generalise the warnings counter from the original Tawhiri port: // any model or constraint can emit them, the EventSink aggregates by Type, // and each Result carries a summary slice for the API to surface. type Event struct { Type string // short identifier, e.g. "above_model" Time float64 // UNIX seconds when the event was emitted State State Message string } // EventSummary is the per-type aggregation of repeated emissions. type EventSummary struct { Type string `json:"type"` Count int64 `json:"count"` FirstTime float64 `json:"first_time"` LastTime float64 `json:"last_time"` FirstState State `json:"first_state"` LastState State `json:"last_state"` Message string `json:"message"` } // EventSink collects events from models and the integrator, aggregating // duplicate types into a single EventSummary. It also carries the run's first // unrecoverable failure, which is a different thing from an event — see Fail. // Safe for concurrent use. type EventSink struct { mu sync.Mutex summaries map[string]*EventSummary err error } // NewEventSink returns an empty sink. func NewEventSink() *EventSink { return &EventSink{summaries: make(map[string]*EventSummary)} } // Emit records one occurrence of typ at (t, s) with the provided message. // Subsequent emits with the same typ update LastTime/LastState and Count. func (s *EventSink) Emit(typ string, t float64, state State, message string) { if s == nil { return } s.mu.Lock() defer s.mu.Unlock() sum, ok := s.summaries[typ] if !ok { s.summaries[typ] = &EventSummary{ Type: typ, Count: 1, FirstTime: t, LastTime: t, FirstState: state, LastState: state, Message: message, } return } sum.Count++ sum.LastTime = t sum.LastState = state if sum.Message == "" && message != "" { sum.Message = message } } // Fail records the run's first unrecoverable error. Nil-safe, like Emit. // // Deliberately not an event. An event is an observation the caller may choose to // ignore: "above_model" means samples above the highest pressure level were // extrapolated, which degrades the answer without invalidating it. A failure // means the sampler had no data at all, so the trajectory from that point on is // not a forecast and must not be returned as one. // // Routing both through Emit is how the second came to be treated like the first. // WindTransport discarded the sampler's error and returned a zero rate, which is // indistinguishable from calm air, so any launch past the dataset's horizon // integrated with no wind and produced a balloon that took off and landed on the // same spot — reported as a successful prediction. // // Only the first failure is kept: it is the one that explains the run. func (s *EventSink) Fail(err error) { if s == nil || err == nil { return } s.mu.Lock() defer s.mu.Unlock() if s.err == nil { s.err = err } } // Err returns the first failure passed to Fail, or nil if the run was sound. func (s *EventSink) Err() error { if s == nil { return nil } s.mu.Lock() defer s.mu.Unlock() return s.err } // Snapshot returns a stable copy of every summary in deterministic order // (sorted by Type). func (s *EventSink) Snapshot() []EventSummary { if s == nil { return nil } s.mu.Lock() defer s.mu.Unlock() out := make([]EventSummary, 0, len(s.summaries)) for _, sum := range s.summaries { out = append(out, *sum) } sortEventSummaries(out) return out } func sortEventSummaries(s []EventSummary) { // Insertion sort: usually one or two entries. for i := 1; i < len(s); i++ { j := i for j > 0 && s[j-1].Type > s[j].Type { s[j-1], s[j] = s[j], s[j-1] j-- } } }