From 25712b0eae680e64888d727aa4b3cdcaa137477c Mon Sep 17 00:00:00 2001 From: Josep Garcia-Reyero Sais Date: Fri, 31 Jul 2026 15:45:17 +0200 Subject: [PATCH 1/8] feat(dbtsl): add a direct dbt Semantic Layer client lfx-mcp reaches the semantic layer by calling lfx-lens over HTTP, which then calls the dbt Semantic Layer. lfx-lens is the text-to-SQL service and performs no per-user authorization on those routes, so the hop buys nothing and costs latency, a failure mode, and a second release train. This is the client that removes it. There is no Go SDK for the dbt Semantic Layer. Both reference implementations, lfx-lens and dbt Labs' own dbt-mcp, are Python and split the transport: GraphQL for metadata, Arrow Flight over gRPC for execution. This client uses GraphQL for both, which avoids an Arrow, gRPC and session-lifecycle dependency for no loss at our volumes: the tools cap limit at 500, well inside the API's 1024-row page. Ported from lfx-lens ai/services/dbt_semantic_layer.py, minus the project scope check, which queries Snowflake and is not a security boundary given the tools are staff-gated and project_slug is optional. A live parity harness sits behind the 'parity' build tag. It caught three things a stub could not: - The published docs name the createQuery argument 'order'; the deployed schema calls it 'orderBy'. Every ordered query would have failed. Schema introspection is the source of truth. - Metric values decoded as float64 reached the model as 4.239559e+06 rather than 4239559. Numbers are now decoded as json.Number. - Callers write time grains as 'metric_time__year', but the GraphQL API takes the grain as its own field. The Arrow path hides this, so every time series query would have broken. Verified against the live semantic layer: 59 of 59 allowlisted metrics reachable, country__lf_region returns the 9 expected values including 'Asia Pacific', a 'viet' search returns 'Viet Nam', and both search fallbacks rescue a plural and a natural-language query. Warm queries return in about 1.6s. Issue: LFXV2-2940 Signed-off-by: Josep Garcia-Reyero Sais --- internal/dbtsl/allowlist.go | 282 ++++++++++ internal/dbtsl/cache.go | 91 +++ internal/dbtsl/client.go | 208 +++++++ internal/dbtsl/dbtsl_test.go | 852 +++++++++++++++++++++++++++++ internal/dbtsl/dimensionvalues.go | 189 +++++++ internal/dbtsl/metadata.go | 281 ++++++++++ internal/dbtsl/parity_live_test.go | 324 +++++++++++ internal/dbtsl/query.go | 351 ++++++++++++ internal/dbtsl/search.go | 58 ++ internal/dbtsl/similarity.go | 73 +++ 10 files changed, 2709 insertions(+) create mode 100644 internal/dbtsl/allowlist.go create mode 100644 internal/dbtsl/cache.go create mode 100644 internal/dbtsl/client.go create mode 100644 internal/dbtsl/dbtsl_test.go create mode 100644 internal/dbtsl/dimensionvalues.go create mode 100644 internal/dbtsl/metadata.go create mode 100644 internal/dbtsl/parity_live_test.go create mode 100644 internal/dbtsl/query.go create mode 100644 internal/dbtsl/search.go create mode 100644 internal/dbtsl/similarity.go diff --git a/internal/dbtsl/allowlist.go b/internal/dbtsl/allowlist.go new file mode 100644 index 0000000..023225d --- /dev/null +++ b/internal/dbtsl/allowlist.go @@ -0,0 +1,282 @@ +// Copyright The Linux Foundation and contributors. +// SPDX-License-Identifier: MIT + +package dbtsl + +import ( + "fmt" + "sort" + "strings" +) + +// Insights metric allowlist. +// +// Only these metrics are exposed through the semantic layer tools. They are +// grouped by Insights domain for maintainability: to add a metric, append it +// to the domain it belongs to, and add any new topic word to searchableTopics. + +var membershipsMetrics = []string{ + "membership_revenue", + "renewal_price", + "current_membership_revenue", + "current_membership_count", + "current_new_account_membership_count", + "churned_membership_count", + "last_completed_year_active_membership_count", + "last_completed_year_active_membership_revenue", + "total_discount_amount", + "total_downgrade_churn_amount", + "total_invoice_amount", + "total_next_membership_revenue", + "current_membership_discount_amount", + "current_membership_invoice_amount", + "churned_membership_discount_amount", + "churned_membership_invoice_amount", + "last_completed_year_active_discount_amount", + "last_completed_year_active_invoice_amount", +} + +var activitiesMetrics = []string{ + "total_activities", + "total_code_insertions", + "total_code_deletions", + "total_first_time_contributors", + "total_contributors", + "total_contributing_organizations", + "code_contribution_activities", + "main_branch_commits", + "approved_pull_requests", + "bot_activities", + "human_activities", + "lf_project_activities", +} + +var maintainersMetrics = []string{ + "total_maintainers", + "active_maintainers", + "total_maintainer_records", + "active_maintainer_records", +} + +var healthValueMetrics = []string{ + "avg_project_health_score", + "project_health_count", + "total_software_value", + "total_estimated_cost", +} + +var projectsMetrics = []string{ + "project_count", +} + +var eventsMetrics = []string{ + "total_events", + "past_events_count", + "upcoming_events_count", + "total_registrations", + "total_gross_revenue", + "total_registration_tax", + "total_registration_net_revenue", + "total_event_registrations_goal", + "total_sponsorship_revenue", + "total_sponsorship_count", + "sponsorship_quantity_total", + "total_speakers", + "total_speaking_engagements", + "total_accepted_proposals", + "past_event_speakers", +} + +var educationMetrics = []string{ + "total_enrollments", + "total_enrolled_users", + "training_enrollments", + "certification_enrollments", + "total_certifications", +} + +// metricsAllowlist is the union of every domain above. +var metricsAllowlist = func() map[string]struct{} { + allowlist := make(map[string]struct{}) + for _, domain := range [][]string{ + membershipsMetrics, + activitiesMetrics, + maintainersMetrics, + healthValueMetrics, + projectsMetrics, + eventsMetrics, + educationMetrics, + } { + for _, name := range domain { + allowlist[name] = struct{}{} + } + } + return allowlist +}() + +// searchableTopics are topic words known to match metric names, one group per +// domain above. +// +// Search matches names and descriptions only, so a caller guessing a domain +// word can land on nothing with no way forward. These are what we offer +// instead. Adding a domain means adding its words here, and +// TestEverySearchableTopicMatchesAMetric fails if one stops matching. +var searchableTopics = []string{ + "membership", + "revenue", + "churn", + "contributor", + "contribution", + "activities", + "maintainer", + "health", + "software", + "project", + "event", + "registration", + "sponsorship", + "speaker", + "enrollment", + "certification", +} + +// IsAllowedMetric reports whether name is in the Insights allowlist. +func IsAllowedMetric(name string) bool { + _, ok := metricsAllowlist[name] + return ok +} + +// ValidateMetrics returns the names that are not in the allowlist. +func ValidateMetrics(names []string) []string { + var disallowed []string + for _, name := range names { + if !IsAllowedMetric(name) { + disallowed = append(disallowed, name) + } + } + return disallowed +} + +// AllowedMetricNames returns every allowlisted metric name, sorted. +func AllowedMetricNames() []string { + names := make([]string, 0, len(metricsAllowlist)) + for name := range metricsAllowlist { + names = append(names, name) + } + sort.Strings(names) + return names +} + +// SearchableTopics returns the topic words offered when a search finds nothing. +func SearchableTopics() []string { + return append([]string(nil), searchableTopics...) +} + +// NoMetricsDetail is the rejection message for a search that matched nothing. +// +// Like UnknownMetricsDetail, it never leaves the caller holding an empty +// result with nothing to try next. +func NoMetricsDetail(search string) string { + return fmt.Sprintf( + "No metrics matched %q. Search matches metric names and descriptions "+ + "only, so search by topic. Try one of: %s. Country, region and tier "+ + "are dimensions rather than metrics - pick a metric first, then call "+ + "get_dimensions to see how it can be sliced.", + search, strings.Join(searchableTopics, ", "), + ) +} + +// UnknownMetricsDetail is the rejection message for metric names outside the +// allowlist, naming plausible alternatives for each. +func UnknownMetricsDetail(disallowed []string) string { + var b strings.Builder + fmt.Fprintf(&b, "Metrics not available: %s.", strings.Join(disallowed, ", ")) + for _, name := range disallowed { + if suggestions := SuggestMetrics(name, 5); len(suggestions) > 0 { + fmt.Fprintf(&b, " Did you mean (for %q): %s?", name, strings.Join(suggestions, ", ")) + } + } + b.WriteString(" Use list_metrics to see what is available.") + return b.String() +} + +// SuggestMetrics returns allowlisted metric names close to name. +// +// A caller that guesses a metric ("contributor_count" for "total_contributors") +// would otherwise get a rejection with no way forward, so the error can name +// plausible alternatives instead. Matches on shared underscore-separated words +// first, then falls back to fuzzy similarity. +func SuggestMetrics(name string, limit int) []string { + if limit <= 0 { + return nil + } + + var words []string + for _, w := range strings.Split(strings.ToLower(name), "_") { + if w != "" { + words = append(words, w) + } + } + + type scored struct { + score int + name string + } + + // Substring rather than whole-word matching, so "contributor" reaches + // "total_contributors". Score by matched word length, not match count, so + // a distinctive term ("contributor") outranks a generic one ("count") that + // half the allowlist shares. Ties break on name for a stable order. + var matches []scored + for metric := range metricsAllowlist { + lower := strings.ToLower(metric) + score := 0 + for _, w := range words { + if strings.Contains(lower, w) { + score += len(w) + } + } + if score > 0 { + matches = append(matches, scored{score: score, name: metric}) + } + } + if len(matches) > 0 { + sort.Slice(matches, func(i, j int) bool { + if matches[i].score != matches[j].score { + return matches[i].score > matches[j].score + } + return matches[i].name < matches[j].name + }) + if len(matches) > limit { + matches = matches[:limit] + } + names := make([]string, 0, len(matches)) + for _, m := range matches { + names = append(names, m.name) + } + return names + } + + // Fallback: closest by similarity, mirroring difflib.get_close_matches + // with its default 0.6 cutoff. + const cutoff = 0.6 + type ranked struct { + ratio float64 + name string + } + var close []ranked + for _, metric := range AllowedMetricNames() { + if r := similarityRatio(name, metric); r >= cutoff { + close = append(close, ranked{ratio: r, name: metric}) + } + } + sort.SliceStable(close, func(i, j int) bool { return close[i].ratio > close[j].ratio }) + names := make([]string, 0, limit) + for _, c := range close { + if len(names) == limit { + break + } + names = append(names, c.name) + } + return names +} diff --git a/internal/dbtsl/cache.go b/internal/dbtsl/cache.go new file mode 100644 index 0000000..3e052ed --- /dev/null +++ b/internal/dbtsl/cache.go @@ -0,0 +1,91 @@ +// Copyright The Linux Foundation and contributors. +// SPDX-License-Identifier: MIT + +package dbtsl + +import ( + "sync" + "time" +) + +// ttlCache is an in-memory TTL cache with a bounded entry count. +// +// An expired entry is dropped when accessed, expired entries are swept on +// put, and the entry closest to expiry is evicted when the cache is full and +// a new key arrives. Unlike the Python original, which relied on the GIL and a +// single event loop, this is guarded by a mutex: the MCP server handles +// requests concurrently across goroutines. +type ttlCache[K comparable, V any] struct { + mu sync.Mutex + ttl time.Duration + maxEntries int + store map[K]cacheEntry[V] +} + +type cacheEntry[V any] struct { + expiry time.Time + value V +} + +func newTTLCache[K comparable, V any](ttl time.Duration, maxEntries int) *ttlCache[K, V] { + return &ttlCache[K, V]{ + ttl: ttl, + maxEntries: maxEntries, + store: make(map[K]cacheEntry[V]), + } +} + +// get returns the cached value for key, and whether it was present and unexpired. +func (c *ttlCache[K, V]) get(key K) (V, bool) { + c.mu.Lock() + defer c.mu.Unlock() + + entry, ok := c.store[key] + if !ok { + var zero V + return zero, false + } + if time.Now().After(entry.expiry) { + delete(c.store, key) + var zero V + return zero, false + } + return entry.value, true +} + +// put stores value under key, sweeping expired entries first. +func (c *ttlCache[K, V]) put(key K, value V) { + c.mu.Lock() + defer c.mu.Unlock() + + now := time.Now() + for k, entry := range c.store { + if !entry.expiry.After(now) { + delete(c.store, k) + } + } + + // Evict only when inserting a new key into a full cache. + if _, exists := c.store[key]; !exists && len(c.store) >= c.maxEntries { + var oldestKey K + var oldestExpiry time.Time + first := true + for k, entry := range c.store { + if first || entry.expiry.Before(oldestExpiry) { + oldestKey, oldestExpiry, first = k, entry.expiry, false + } + } + if !first { + delete(c.store, oldestKey) + } + } + + c.store[key] = cacheEntry[V]{expiry: now.Add(c.ttl), value: value} +} + +// clear drops every entry. +func (c *ttlCache[K, V]) clear() { + c.mu.Lock() + defer c.mu.Unlock() + c.store = make(map[K]cacheEntry[V]) +} diff --git a/internal/dbtsl/client.go b/internal/dbtsl/client.go new file mode 100644 index 0000000..caa8bc3 --- /dev/null +++ b/internal/dbtsl/client.go @@ -0,0 +1,208 @@ +// Copyright The Linux Foundation and contributors. +// SPDX-License-Identifier: MIT + +// Package dbtsl provides a client for the dbt Semantic Layer API. +// +// The dbt Semantic Layer is the governed definition of every LFX Insights +// metric. This client talks to it directly over the GraphQL API, for both +// metadata (metrics, dimensions) and query execution. +// +// That is a deliberate divergence from the two Python reference +// implementations, lfx-lens and dbt Labs' own dbt-mcp server, which use the +// dbtsl SDK and split the transport: GraphQL for metadata, Arrow Flight over +// gRPC for execution. There is no Go SDK for the dbt Semantic Layer, and +// reproducing the Flight path would mean taking on Arrow, gRPC and session +// lifecycle for no benefit at the volumes this server queries. Callers are +// capped at 500 rows, comfortably inside the GraphQL API's 1024-row page, so +// pagination never engages. +// +// Access to metrics is gated by an allowlist (see allowlist.go). Dimension +// value discovery is gated on the caller supplying the metrics the dimension +// belongs to, because a dimension-only query does not consult the metric +// allowlist at all. +package dbtsl + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strconv" + "strings" + "time" +) + +const ( + // defaultTimeout bounds a single GraphQL round trip. Query execution + // polls, so its overall bound comes from the caller's context instead. + defaultTimeout = 30 * time.Second + + oneWeek = 7 * 24 * time.Hour + + // metricsCacheTTL and dimensionsCacheTTL: metadata only changes on a dbt + // deploy, so a long TTL is safe. + metricsCacheTTL = oneWeek + dimensionsCacheTTL = oneWeek + + // dimensionValuesCacheTTL is much shorter: values track the warehouse + // rather than the dbt deploy, so they cannot share the metadata TTL. + dimensionValuesCacheTTL = time.Hour +) + +// Config holds the settings needed to reach the dbt Semantic Layer. +type Config struct { + // Host is the Semantic Layer hostname, without scheme or path, + // e.g. "tj283.semantic-layer.us1.dbt.com". + Host string + // EnvironmentID is the dbt environment to query. + EnvironmentID string + // Token is the dbt service token. + Token string + // HTTPClient is optional. When nil a client with defaultTimeout is used. + // + // Do not pass a client wrapped in the serviceapi debug transport: it dumps + // the Authorization header, which would print this long-lived service + // token into logs whenever debug traffic is enabled. + HTTPClient *http.Client +} + +// Client queries the dbt Semantic Layer. +type Client struct { + graphqlURL string + environmentID int64 + token string + httpClient *http.Client + + metricsCache *ttlCache[string, []MetricInfo] + dimensionsCache *ttlCache[string, []DimensionInfo] + dimensionValuesCache *ttlCache[string, []string] +} + +// NewClient validates cfg and returns a ready client. +func NewClient(cfg Config) (*Client, error) { + host := strings.TrimSpace(cfg.Host) + host = strings.TrimPrefix(strings.TrimPrefix(host, "https://"), "http://") + host = strings.TrimSuffix(host, "/") + if host == "" { + return nil, fmt.Errorf("dbt Semantic Layer host is required") + } + if strings.TrimSpace(cfg.Token) == "" { + return nil, fmt.Errorf("dbt Semantic Layer token is required") + } + envID := strings.TrimSpace(cfg.EnvironmentID) + if envID == "" { + return nil, fmt.Errorf("dbt Semantic Layer environment ID is required") + } + parsedEnvID, err := strconv.ParseInt(envID, 10, 64) + if err != nil { + return nil, fmt.Errorf("dbt Semantic Layer environment ID %q is not a number: %w", envID, err) + } + + httpClient := cfg.HTTPClient + if httpClient == nil { + httpClient = &http.Client{Timeout: defaultTimeout} + } + + return &Client{ + graphqlURL: "https://" + host + "/api/graphql", + environmentID: parsedEnvID, + token: cfg.Token, + httpClient: httpClient, + metricsCache: newTTLCache[string, []MetricInfo](metricsCacheTTL, 50), + dimensionsCache: newTTLCache[string, []DimensionInfo](dimensionsCacheTTL, 100), + dimensionValuesCache: newTTLCache[string, []string](dimensionValuesCacheTTL, 200), + }, nil +} + +// ClearCaches drops every cached metadata and dimension value entry. +func (c *Client) ClearCaches() { + c.metricsCache.clear() + c.dimensionsCache.clear() + c.dimensionValuesCache.clear() +} + +// graphqlError is one entry in a GraphQL response's errors array. +type graphqlError struct { + Message string `json:"message"` +} + +// graphqlResponse is the envelope every GraphQL reply arrives in. +type graphqlResponse struct { + Data json.RawMessage `json:"data"` + Errors []graphqlError `json:"errors"` +} + +// graphqlRequest executes query against the Semantic Layer and decodes the +// response's data field into out. +// +// environmentId is injected into variables automatically, since every +// operation in this API takes it. +func (c *Client) graphqlRequest(ctx context.Context, query string, variables map[string]any, out any) error { + vars := make(map[string]any, len(variables)+1) + for k, v := range variables { + vars[k] = v + } + vars["environmentId"] = c.environmentID + + payload, err := json.Marshal(map[string]any{"query": query, "variables": vars}) + if err != nil { + return fmt.Errorf("failed to encode GraphQL request: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.graphqlURL, bytes.NewReader(payload)) + if err != nil { + return fmt.Errorf("failed to build GraphQL request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+c.token) + req.Header.Set("Content-Type", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("semantic layer request failed: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("failed to read semantic layer response: %w", err) + } + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("semantic layer returned HTTP %d: %s", resp.StatusCode, truncateForError(string(body))) + } + + var envelope graphqlResponse + if err := json.Unmarshal(body, &envelope); err != nil { + return fmt.Errorf("failed to decode semantic layer response: %w", err) + } + if len(envelope.Errors) > 0 { + messages := make([]string, 0, len(envelope.Errors)) + for _, e := range envelope.Errors { + msg := e.Message + if msg == "" { + msg = "Unknown error" + } + messages = append(messages, msg) + } + return fmt.Errorf("semantic layer GraphQL error: %s", strings.Join(messages, "; ")) + } + if len(envelope.Data) == 0 { + return fmt.Errorf("semantic layer returned no data") + } + + if err := json.Unmarshal(envelope.Data, out); err != nil { + return fmt.Errorf("failed to decode semantic layer data: %w", err) + } + return nil +} + +// truncateForError keeps an upstream error body short enough to be readable +// when it is surfaced to a model. +func truncateForError(s string) string { + const limit = 512 + if len(s) <= limit { + return s + } + return s[:limit] + "..." +} diff --git a/internal/dbtsl/dbtsl_test.go b/internal/dbtsl/dbtsl_test.go new file mode 100644 index 0000000..d9c5813 --- /dev/null +++ b/internal/dbtsl/dbtsl_test.go @@ -0,0 +1,852 @@ +// Copyright The Linux Foundation and contributors. +// SPDX-License-Identifier: MIT + +package dbtsl + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +// stubServer stands in for the dbt Semantic Layer GraphQL API. Each request is +// routed by the GraphQL operation name in the query body, and the matching +// handler returns whatever the test wants for that operation. +type stubServer struct { + t *testing.T + responses map[string][]string // operation -> queued response bodies + calls map[string]int + requests []map[string]any + server *httptest.Server +} + +func newStubServer(t *testing.T) *stubServer { + t.Helper() + s := &stubServer{ + t: t, + responses: make(map[string][]string), + calls: make(map[string]int), + } + s.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + + var req map[string]any + if err := json.Unmarshal(body, &req); err != nil { + s.t.Errorf("stub received malformed JSON: %v", err) + } + s.requests = append(s.requests, req) + + query, _ := req["query"].(string) + op := operationOf(query) + s.calls[op]++ + + queued := s.responses[op] + if len(queued) == 0 { + s.t.Errorf("stub had no queued response for operation %q", op) + w.WriteHeader(http.StatusInternalServerError) + return + } + next := queued[0] + if len(queued) > 1 { + s.responses[op] = queued[1:] + } + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(next)) + })) + t.Cleanup(s.server.Close) + return s +} + +// queue adds a response body for an operation. Responses are consumed in +// order; the last one queued is reused for any further calls. +func (s *stubServer) queue(operation, body string) { + s.responses[operation] = append(s.responses[operation], body) +} + +// lastVariables returns the variables sent on the most recent request. +func (s *stubServer) lastVariables() map[string]any { + s.t.Helper() + if len(s.requests) == 0 { + s.t.Fatal("no requests were made") + } + vars, _ := s.requests[len(s.requests)-1]["variables"].(map[string]any) + return vars +} + +// operationOf extracts the GraphQL operation name from a query document. +func operationOf(query string) string { + for _, line := range strings.Split(query, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + for _, prefix := range []string{"query ", "mutation "} { + if strings.HasPrefix(line, prefix) { + rest := strings.TrimPrefix(line, prefix) + if idx := strings.IndexAny(rest, "( {"); idx > 0 { + return rest[:idx] + } + return rest + } + } + } + return "unknown" +} + +func (s *stubServer) client(t *testing.T) *Client { + t.Helper() + client, err := NewClient(Config{ + Host: strings.TrimPrefix(s.server.URL, "http://"), + EnvironmentID: "202356", + Token: "test-token", + }) + if err != nil { + t.Fatalf("failed to build client: %v", err) + } + // The stub is plain HTTP, so point the client at it directly rather than + // through the https URL NewClient builds. + client.graphqlURL = s.server.URL + return client +} + +// --------------------------------------------------------------------------- +// Allowlist and suggestions +// --------------------------------------------------------------------------- + +// TestEverySearchableTopicMatchesAMetric pins the topic words offered on an +// empty search to the allowlist. A topic that stops matching sends the caller +// somewhere that returns nothing, which is the failure this list exists to +// prevent. +func TestEverySearchableTopicMatchesAMetric(t *testing.T) { + for _, topic := range SearchableTopics() { + matched := false + for _, metric := range AllowedMetricNames() { + if strings.Contains(metric, topic) { + matched = true + break + } + } + if !matched { + t.Errorf("topic %q matches no allowlisted metric name", topic) + } + } +} + +func TestSuggestMetricsRecoversAGuessedName(t *testing.T) { + suggestions := SuggestMetrics("contributor_count", 5) + if !contains(suggestions, "total_contributors") { + t.Errorf("expected total_contributors among suggestions, got %v", suggestions) + } +} + +func TestSuggestMetricsRanksDistinctiveWordsFirst(t *testing.T) { + // "contributor" is distinctive, "count" is shared by much of the + // allowlist, so contributor metrics must outrank generic count metrics. + suggestions := SuggestMetrics("contributor_count", 3) + if len(suggestions) == 0 { + t.Fatal("expected suggestions") + } + if !strings.Contains(suggestions[0], "contribut") { + t.Errorf("expected a contributor metric first, got %v", suggestions) + } +} + +func TestSuggestMetricsRespectsLimit(t *testing.T) { + if got := len(SuggestMetrics("total", 2)); got != 2 { + t.Errorf("expected 2 suggestions, got %d", got) + } +} + +func TestValidateMetricsRejectsUnknown(t *testing.T) { + disallowed := ValidateMetrics([]string{"total_contributors", "user__email", "made_up"}) + if len(disallowed) != 2 { + t.Fatalf("expected 2 disallowed, got %v", disallowed) + } +} + +func TestNoMetricsDetailNamesTopicsAndTheDimensionTrap(t *testing.T) { + detail := NoMetricsDetail("vietnam") + if !strings.Contains(detail, "membership") { + t.Error("expected the message to name topic words") + } + if !strings.Contains(detail, "dimensions rather than metrics") { + t.Error("expected the message to explain that country and region are dimensions") + } +} + +// --------------------------------------------------------------------------- +// Similarity, mirroring difflib.SequenceMatcher.ratio() +// --------------------------------------------------------------------------- + +func TestSimilarityRatioMatchesDifflib(t *testing.T) { + tests := []struct { + a, b string + want float64 + }{ + {"", "", 1}, + {"abcd", "abcd", 1}, + {"abcd", "bcde", 0.75}, // longest run "bcd", 2*3/8 + {"abc", "xyz", 0}, // nothing in common + {"ab", "abcdef", 0.5}, // 2*2/8 + // Only single-character runs match, and the algorithm commits to the + // earliest one rather than the one that would score best overall. + {"tide", "diet", 0.25}, + // Two values from the domain, as regression anchors. + {"contributor_count", "total_contributors", 0.6285714285714286}, + {"membership", "memberships", 0.9523809523809523}, + } + for _, tc := range tests { + if got := similarityRatio(tc.a, tc.b); !nearlyEqual(got, tc.want) { + t.Errorf("similarityRatio(%q, %q) = %v, want %v", tc.a, tc.b, got, tc.want) + } + } +} + +func nearlyEqual(a, b float64) bool { + const epsilon = 1e-9 + diff := a - b + return diff < epsilon && diff > -epsilon +} + +// --------------------------------------------------------------------------- +// Search fallbacks +// --------------------------------------------------------------------------- + +func TestSingularVariants(t *testing.T) { + tests := []struct { + word string + want []string + }{ + {"contributions", []string{"contributions", "contribution"}}, + {"activities", []string{"activities", "activity"}}, + {"addresses", []string{"addresses", "address"}}, + {"as", []string{"as"}}, // too short to stem + {"health", []string{"health"}}, // no plural suffix + } + for _, tc := range tests { + got := singularVariants(tc.word) + if strings.Join(got, ",") != strings.Join(tc.want, ",") { + t.Errorf("singularVariants(%q) = %v, want %v", tc.word, got, tc.want) + } + } +} + +func TestMatchAnyWordRescuesAPluralSearch(t *testing.T) { + metrics := []MetricInfo{ + {Name: "code_contribution_activities", Description: "Contribution activity"}, + {Name: "total_events", Description: "Events"}, + {Name: "not_allowlisted_metric", Description: "contribution"}, + } + matched := matchAnyWord(metrics, "contributions") + if len(matched) != 1 || matched[0].Name != "code_contribution_activities" { + t.Errorf("expected only the allowlisted contribution metric, got %v", matched) + } +} + +func TestMatchAnyWordRescuesAMultiWordSearch(t *testing.T) { + metrics := []MetricInfo{ + {Name: "total_contributing_organizations", Description: "Organizations contributing"}, + {Name: "total_events", Description: "Events"}, + } + matched := matchAnyWord(metrics, "contributor organization country") + if len(matched) != 1 || matched[0].Name != "total_contributing_organizations" { + t.Errorf("expected the contributing organizations metric, got %v", matched) + } +} + +// --------------------------------------------------------------------------- +// Query construction +// --------------------------------------------------------------------------- + +// TestSplitTimeGrain covers the translation the GraphQL API forces on us: +// callers write "metric_time__month" the way the dbt SDK accepts it, but the +// API takes the grain as its own field. +func TestSplitTimeGrain(t *testing.T) { + tests := []struct { + name string + wantBase string + wantGrain string + wantOK bool + }{ + {"metric_time__month", "metric_time", "MONTH", true}, + {"metric_time__year", "metric_time", "YEAR", true}, + {"created_at__day", "created_at", "DAY", true}, + {"country__lf_region", "", "", false}, + {"metric_time", "", "", false}, + {"asset_id__membership_tier", "", "", false}, + } + for _, tc := range tests { + base, grain, ok := splitTimeGrain(tc.name) + if ok != tc.wantOK || base != tc.wantBase || grain != tc.wantGrain { + t.Errorf("splitTimeGrain(%q) = (%q, %q, %v), want (%q, %q, %v)", + tc.name, base, grain, ok, tc.wantBase, tc.wantGrain, tc.wantOK) + } + } +} + +func TestBuildGroupByInputsSeparatesGrain(t *testing.T) { + inputs := buildGroupByInputs([]string{"country__lf_region", "metric_time__month", " "}) + if len(inputs) != 2 { + t.Fatalf("expected 2 inputs, got %d", len(inputs)) + } + if inputs[0]["name"] != "country__lf_region" { + t.Errorf("expected the plain dimension untouched, got %v", inputs[0]) + } + if _, hasGrain := inputs[0]["grain"]; hasGrain { + t.Error("a non-time dimension must not carry a grain") + } + if inputs[1]["name"] != "metric_time" || inputs[1]["grain"] != "MONTH" { + t.Errorf("expected metric_time split from MONTH, got %v", inputs[1]) + } +} + +func TestBuildOrderByInputsDistinguishesMetricsFromGroupBys(t *testing.T) { + metrics := []string{"total_contributors"} + inputs := buildOrderByInputs([]string{"-total_contributors", "country__lf_region"}, metrics) + if len(inputs) != 2 { + t.Fatalf("expected 2 inputs, got %d", len(inputs)) + } + + if inputs[0]["descending"] != true { + t.Error("expected the - prefix to mean descending") + } + if _, isMetric := inputs[0]["metric"]; !isMetric { + t.Errorf("expected a query metric to order as a metric, got %v", inputs[0]) + } + + if inputs[1]["descending"] != false { + t.Error("expected no prefix to mean ascending") + } + if _, isGroupBy := inputs[1]["groupBy"]; !isGroupBy { + t.Errorf("expected a non-metric to order as a groupBy, got %v", inputs[1]) + } +} + +func TestParseQueryResultStripsTheSyntheticIndex(t *testing.T) { + raw := `{ + "schema": { + "fields": [ + {"name": "index", "type": "integer"}, + {"name": "country__lf_region", "type": "string"}, + {"name": "total_contributors", "type": "integer"} + ], + "primaryKey": ["index"] + }, + "data": [ + {"index": 0, "country__lf_region": "Asia Pacific", "total_contributors": 12}, + {"index": 1, "country__lf_region": "Europe", "total_contributors": 34} + ] + }` + + result, err := parseQueryResult(raw, "SELECT 1") + if err != nil { + t.Fatalf("parseQueryResult failed: %v", err) + } + if strings.Join(result.Columns, ",") != "country__lf_region,total_contributors" { + t.Errorf("expected the index column stripped, got %v", result.Columns) + } + if result.RowCount != 2 { + t.Errorf("expected 2 rows, got %d", result.RowCount) + } + if _, present := result.Data[0]["index"]; present { + t.Error("expected the index key stripped from rows") + } + if result.CompiledSQL != "SELECT 1" { + t.Errorf("expected the compiled SQL carried through, got %q", result.CompiledSQL) + } +} + +func TestParseQueryResultHandlesAnEmptyBody(t *testing.T) { + result, err := parseQueryResult("", "") + if err != nil { + t.Fatalf("parseQueryResult failed: %v", err) + } + if result.RowCount != 0 || len(result.Columns) != 0 { + t.Errorf("expected an empty result, got %+v", result) + } +} + +// --------------------------------------------------------------------------- +// Query execution over the poll loop +// --------------------------------------------------------------------------- + +const successfulResultJSON = `{"data":{"query":{"status":"SUCCESSFUL","error":null,"sql":"SELECT 1","jsonResult":"{\"schema\":{\"fields\":[{\"name\":\"index\",\"type\":\"integer\"},{\"name\":\"country__lf_region\",\"type\":\"string\"}],\"primaryKey\":[\"index\"]},\"data\":[{\"index\":0,\"country__lf_region\":\"Asia Pacific\"}]}"}}}` + +func TestQueryPollsUntilSuccessful(t *testing.T) { + stub := newStubServer(t) + stub.queue("CreateQuery", `{"data":{"createQuery":{"queryId":"q-1"}}}`) + stub.queue("GetQueryResult", `{"data":{"query":{"status":"RUNNING","error":null,"sql":null,"jsonResult":null}}}`) + stub.queue("GetQueryResult", successfulResultJSON) + + client := stub.client(t) + result, err := client.Query(context.Background(), QueryArgs{Metrics: []string{"total_contributors"}}) + if err != nil { + t.Fatalf("Query failed: %v", err) + } + if result.RowCount != 1 { + t.Errorf("expected 1 row, got %d", result.RowCount) + } + if stub.calls["GetQueryResult"] != 2 { + t.Errorf("expected 2 polls, got %d", stub.calls["GetQueryResult"]) + } +} + +func TestQuerySurfacesAFailureAsAnApplicationError(t *testing.T) { + stub := newStubServer(t) + stub.queue("CreateQuery", `{"data":{"createQuery":{"queryId":"q-1"}}}`) + stub.queue("GetQueryResult", `{"data":{"query":{"status":"FAILED","error":"Unable to resolve metric","sql":null,"jsonResult":null}}}`) + + client := stub.client(t) + _, err := client.Query(context.Background(), QueryArgs{Metrics: []string{"total_contributors"}}) + + var queryErr *QueryFailedError + if !errors.As(err, &queryErr) { + t.Fatalf("expected a QueryFailedError, got %v", err) + } + if !strings.Contains(queryErr.Message, "Unable to resolve metric") { + t.Errorf("expected the upstream reason preserved, got %q", queryErr.Message) + } +} + +func TestQueryStopsWhenTheContextIsCancelled(t *testing.T) { + stub := newStubServer(t) + stub.queue("CreateQuery", `{"data":{"createQuery":{"queryId":"q-1"}}}`) + stub.queue("GetQueryResult", `{"data":{"query":{"status":"RUNNING","error":null,"sql":null,"jsonResult":null}}}`) + + client := stub.client(t) + ctx, cancel := context.WithTimeout(context.Background(), 400*time.Millisecond) + defer cancel() + + if _, err := client.Query(ctx, QueryArgs{Metrics: []string{"total_contributors"}}); err == nil { + t.Fatal("expected the query to stop when the context expired") + } +} + +func TestQuerySurfacesGraphQLErrors(t *testing.T) { + stub := newStubServer(t) + stub.queue("CreateQuery", `{"errors":[{"message":"Metric 'nope' not found"}]}`) + + client := stub.client(t) + _, err := client.Query(context.Background(), QueryArgs{Metrics: []string{"nope"}}) + if err == nil || !strings.Contains(err.Error(), "Metric 'nope' not found") { + t.Fatalf("expected the GraphQL error surfaced, got %v", err) + } +} + +// --------------------------------------------------------------------------- +// Dimension values: the gate, the guard, the escape +// --------------------------------------------------------------------------- + +const dimensionsForContributors = `{"data":{"dimensionsPaginated":{"items":[ + {"name":"country__lf_region","type":"categorical","description":"Region","label":"Region","queryableGranularities":[]}, + {"name":"country__country_name","type":"categorical","description":"Country","label":"Country","queryableGranularities":[]} +]}}}` + +func TestFetchDimensionValuesRejectsAnInjectionShapedName(t *testing.T) { + stub := newStubServer(t) + client := stub.client(t) + + _, err := client.FetchDimensionValues(context.Background(), + "country__lf_region') }} = 'x' OR 1=1 --", []string{"total_contributors"}, "", 100) + + var unknown *UnknownDimensionError + if !errors.As(err, &unknown) { + t.Fatalf("expected an UnknownDimensionError, got %v", err) + } + if len(stub.requests) != 0 { + t.Error("expected the name rejected before any request was made") + } +} + +func TestFetchDimensionValuesRejectsAMetricOutsideTheAllowlist(t *testing.T) { + stub := newStubServer(t) + client := stub.client(t) + + _, err := client.FetchDimensionValues(context.Background(), + "user__email", []string{"some_internal_metric"}, "", 100) + + var unknown *UnknownDimensionError + if !errors.As(err, &unknown) { + t.Fatalf("expected an UnknownDimensionError, got %v", err) + } + if len(stub.requests) != 0 { + t.Error("expected the metric rejected before any request was made") + } +} + +// TestFetchDimensionValuesRejectsADimensionTheMetricDoesNotExpose is the load +// bearing half of the gate: a dimension-only query does not consult the metric +// allowlist, so without this check any dimension in the semantic layer, +// including PII-bearing ones, would be enumerable. +func TestFetchDimensionValuesRejectsADimensionTheMetricDoesNotExpose(t *testing.T) { + stub := newStubServer(t) + stub.queue("GetDimensions", dimensionsForContributors) + client := stub.client(t) + + _, err := client.FetchDimensionValues(context.Background(), + "user__email", []string{"total_contributors"}, "", 100) + + var unknown *UnknownDimensionError + if !errors.As(err, &unknown) { + t.Fatalf("expected an UnknownDimensionError, got %v", err) + } + if !strings.Contains(unknown.Message, "get_dimensions") { + t.Errorf("expected the message to name the way forward, got %q", unknown.Message) + } + if stub.calls["CreateQuery"] != 0 { + t.Error("expected no query to run for a dimension outside the metric") + } +} + +func TestFetchDimensionValuesBuildsAnILIKEFilter(t *testing.T) { + stub := newStubServer(t) + stub.queue("GetDimensions", dimensionsForContributors) + stub.queue("CreateQuery", `{"data":{"createQuery":{"queryId":"q-1"}}}`) + stub.queue("GetQueryResult", `{"data":{"query":{"status":"SUCCESSFUL","error":null,"sql":"","jsonResult":"{\"schema\":{\"fields\":[{\"name\":\"country__country_name\",\"type\":\"string\"}],\"primaryKey\":[]},\"data\":[{\"country__country_name\":\"Viet Nam\"}]}"}}}`) + + client := stub.client(t) + values, err := client.FetchDimensionValues(context.Background(), + "country__country_name", []string{"total_contributors"}, "viet", 100) + if err != nil { + t.Fatalf("FetchDimensionValues failed: %v", err) + } + if len(values.Values) != 1 || values.Values[0] != "Viet Nam" { + t.Errorf("expected the ISO spelling returned, got %v", values.Values) + } + + // The create call carries the filter and no metrics. + var createVars map[string]any + for _, req := range stub.requests { + if query, _ := req["query"].(string); operationOf(query) == "CreateQuery" { + createVars, _ = req["variables"].(map[string]any) + } + } + where, _ := createVars["where"].([]any) + if len(where) != 1 { + t.Fatalf("expected one where clause, got %v", createVars["where"]) + } + clause, _ := where[0].(map[string]any)["sql"].(string) + if !strings.Contains(clause, "ILIKE '%viet%'") { + t.Errorf("expected an ILIKE filter, got %q", clause) + } + if metrics, _ := createVars["metrics"].([]any); len(metrics) != 0 { + t.Errorf("expected a dimension-only query to pass no metrics, got %v", metrics) + } +} + +func TestEscapeSQLLiteralEscapesQuotes(t *testing.T) { + if got := escapeSQLLiteral("d'Ivoire"); got != "d''Ivoire" { + t.Errorf("escapeSQLLiteral(d'Ivoire) = %q, want d''Ivoire", got) + } + if got := escapeSQLLiteral(`back\slash`); got != `back\\slash` { + t.Errorf("expected the backslash escaped, got %q", got) + } +} + +func TestDimensionValuesFlagsTruncation(t *testing.T) { + full := newDimensionValues("country__country_name", []string{"a", "b"}, 2) + if !full.Truncated { + t.Error("expected a result at the limit to be flagged truncated") + } + partial := newDimensionValues("country__country_name", []string{"a"}, 2) + if partial.Truncated { + t.Error("expected a result under the limit not to be flagged truncated") + } +} + +func TestDistinctValuesDropsNullsAndDuplicates(t *testing.T) { + result := &QueryResult{ + Columns: []string{"country__lf_region"}, + Data: []map[string]any{ + {"country__lf_region": "Europe"}, + {"country__lf_region": nil}, + {"country__lf_region": "Asia Pacific"}, + {"country__lf_region": "Europe"}, + }, + } + values := distinctValues(result, "country__lf_region") + if strings.Join(values, ",") != "Asia Pacific,Europe" { + t.Errorf("expected sorted distinct non-null values, got %v", values) + } +} + +func TestNoDimensionValuesDetailNamesTheISOSpelling(t *testing.T) { + detail := NoDimensionValuesDetail("country__country_name", "vietnam") + if !strings.Contains(detail, "Viet Nam") { + t.Error("expected the message to name the ISO spelling") + } + if !strings.Contains(NoDimensionValuesDetail("x", ""), "no non-null values") { + t.Error("expected a different message when there was no search") + } +} + +// --------------------------------------------------------------------------- +// Metadata +// --------------------------------------------------------------------------- + +func TestFetchAllowedMetricsFiltersToTheAllowlist(t *testing.T) { + stub := newStubServer(t) + stub.queue("GetMetrics", `{"data":{"metricsPaginated":{"items":[ + {"name":"total_contributors","label":"Contributors","description":"Contributors","type":"simple"}, + {"name":"internal_secret_metric","label":"Secret","description":"Not for callers","type":"simple"} + ]}}}`) + stub.queue("GetMetricsWithRelated", `{"data":{"metricsPaginated":{"items":[ + {"name":"total_contributors","label":"Contributors","description":"Contributors","type":"simple","dimensions":[{"name":"country__lf_region"}],"entities":[{"name":"country"}]} + ]}}}`) + + client := stub.client(t) + metrics, err := client.FetchAllowedMetrics(context.Background(), "") + if err != nil { + t.Fatalf("FetchAllowedMetrics failed: %v", err) + } + if len(metrics) != 1 || metrics[0].Name != "total_contributors" { + t.Fatalf("expected only the allowlisted metric, got %v", metrics) + } + if len(metrics[0].Dimensions) != 1 { + t.Errorf("expected dimensions inlined for a small result, got %v", metrics[0].Dimensions) + } +} + +// TestFetchAllowedMetricsRetriesPerWord covers the rescue for a plural or +// natural-language search, which the Semantic Layer matches as a single exact +// phrase and so returns nothing for. +func TestFetchAllowedMetricsRetriesPerWord(t *testing.T) { + stub := newStubServer(t) + // The phrase search finds nothing. + stub.queue("GetMetrics", `{"data":{"metricsPaginated":{"items":[]}}}`) + // The unfiltered retry finds the singular form. + stub.queue("GetMetrics", `{"data":{"metricsPaginated":{"items":[ + {"name":"code_contribution_activities","label":"Contributions","description":"Contribution activity","type":"simple"} + ]}}}`) + stub.queue("GetMetricsWithRelated", `{"data":{"metricsPaginated":{"items":[ + {"name":"code_contribution_activities","label":"Contributions","description":"Contribution activity","type":"simple","dimensions":[{"name":"country__lf_region"}],"entities":[]} + ]}}}`) + + client := stub.client(t) + metrics, err := client.FetchAllowedMetrics(context.Background(), "contributions") + if err != nil { + t.Fatalf("FetchAllowedMetrics failed: %v", err) + } + if len(metrics) != 1 || metrics[0].Name != "code_contribution_activities" { + t.Fatalf("expected the singular stem to rescue the search, got %v", metrics) + } +} + +func TestFetchAllowedMetricsCachesSearches(t *testing.T) { + stub := newStubServer(t) + stub.queue("GetMetrics", `{"data":{"metricsPaginated":{"items":[ + {"name":"total_contributors","label":"Contributors","description":"Contributors","type":"simple"} + ]}}}`) + stub.queue("GetMetricsWithRelated", `{"data":{"metricsPaginated":{"items":[ + {"name":"total_contributors","label":"Contributors","description":"Contributors","type":"simple","dimensions":[],"entities":[]} + ]}}}`) + + client := stub.client(t) + for range 3 { + if _, err := client.FetchAllowedMetrics(context.Background(), "contributor"); err != nil { + t.Fatalf("FetchAllowedMetrics failed: %v", err) + } + } + if stub.calls["GetMetrics"] != 1 { + t.Errorf("expected the search cached after the first call, got %d calls", stub.calls["GetMetrics"]) + } +} + +func TestFetchDimensionsCachesByNormalizedMetricNames(t *testing.T) { + stub := newStubServer(t) + stub.queue("GetDimensions", dimensionsForContributors) + + client := stub.client(t) + first, err := client.FetchDimensions(context.Background(), []string{"total_contributors"}) + if err != nil { + t.Fatalf("FetchDimensions failed: %v", err) + } + // Same metrics, different order and spacing, must hit the same cache entry. + if _, err := client.FetchDimensions(context.Background(), []string{" total_contributors "}); err != nil { + t.Fatalf("FetchDimensions failed: %v", err) + } + if stub.calls["GetDimensions"] != 1 { + t.Errorf("expected one upstream call, got %d", stub.calls["GetDimensions"]) + } + if len(first) != 2 { + t.Errorf("expected 2 dimensions, got %d", len(first)) + } +} + +func TestFetchDimensionsSendsSortedMetricInputs(t *testing.T) { + stub := newStubServer(t) + stub.queue("GetDimensions", dimensionsForContributors) + + client := stub.client(t) + if _, err := client.FetchDimensions(context.Background(), []string{"total_events", "total_contributors"}); err != nil { + t.Fatalf("FetchDimensions failed: %v", err) + } + + metrics, _ := stub.lastVariables()["metrics"].([]any) + if len(metrics) != 2 { + t.Fatalf("expected 2 metric inputs, got %v", metrics) + } + if name, _ := metrics[0].(map[string]any)["name"].(string); name != "total_contributors" { + t.Errorf("expected metric names sorted, got %v", metrics) + } +} + +// --------------------------------------------------------------------------- +// Client construction and transport +// --------------------------------------------------------------------------- + +func TestNewClientValidation(t *testing.T) { + tests := []struct { + name string + cfg Config + }{ + {"missing host", Config{EnvironmentID: "1", Token: "t"}}, + {"missing token", Config{Host: "h", EnvironmentID: "1"}}, + {"missing environment", Config{Host: "h", Token: "t"}}, + {"non-numeric environment", Config{Host: "h", EnvironmentID: "abc", Token: "t"}}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if _, err := NewClient(tc.cfg); err == nil { + t.Error("expected an error") + } + }) + } +} + +func TestNewClientNormalizesTheHost(t *testing.T) { + client, err := NewClient(Config{Host: "https://example.dbt.com/", EnvironmentID: "42", Token: "t"}) + if err != nil { + t.Fatalf("NewClient failed: %v", err) + } + if client.graphqlURL != "https://example.dbt.com/api/graphql" { + t.Errorf("unexpected GraphQL URL %q", client.graphqlURL) + } +} + +func TestGraphQLRequestSendsBearerTokenAndEnvironment(t *testing.T) { + var gotAuth string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAuth = r.Header.Get("Authorization") + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":{"dimensionsPaginated":{"items":[]}}}`)) + })) + defer server.Close() + + client, err := NewClient(Config{Host: "example.dbt.com", EnvironmentID: "202356", Token: "secret-token"}) + if err != nil { + t.Fatalf("NewClient failed: %v", err) + } + client.graphqlURL = server.URL + + if _, err := client.FetchDimensions(context.Background(), []string{"total_contributors"}); err != nil { + t.Fatalf("FetchDimensions failed: %v", err) + } + if gotAuth != "Bearer secret-token" { + t.Errorf("expected a bearer token, got %q", gotAuth) + } +} + +func TestGraphQLRequestSurfacesNonOKStatus(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte("token expired")) + })) + defer server.Close() + + client, err := NewClient(Config{Host: "example.dbt.com", EnvironmentID: "1", Token: "t"}) + if err != nil { + t.Fatalf("NewClient failed: %v", err) + } + client.graphqlURL = server.URL + + _, err = client.FetchDimensions(context.Background(), []string{"total_contributors"}) + if err == nil || !strings.Contains(err.Error(), "401") { + t.Fatalf("expected the HTTP status surfaced, got %v", err) + } +} + +// --------------------------------------------------------------------------- +// Cache +// --------------------------------------------------------------------------- + +func TestTTLCacheExpiresEntries(t *testing.T) { + cache := newTTLCache[string, int](10*time.Millisecond, 10) + cache.put("a", 1) + + if _, ok := cache.get("a"); !ok { + t.Fatal("expected a fresh entry to be present") + } + time.Sleep(20 * time.Millisecond) + if _, ok := cache.get("a"); ok { + t.Error("expected an expired entry to be dropped") + } +} + +func TestTTLCacheEvictsWhenFull(t *testing.T) { + cache := newTTLCache[string, int](time.Minute, 2) + cache.put("a", 1) + cache.put("b", 2) + cache.put("c", 3) + + if len(cache.store) != 2 { + t.Errorf("expected the cache bounded at 2 entries, got %d", len(cache.store)) + } + if _, ok := cache.get("c"); !ok { + t.Error("expected the newest entry retained") + } +} + +func TestTTLCacheOverwriteDoesNotEvict(t *testing.T) { + cache := newTTLCache[string, int](time.Minute, 2) + cache.put("a", 1) + cache.put("b", 2) + cache.put("a", 3) + + if len(cache.store) != 2 { + t.Errorf("expected 2 entries, got %d", len(cache.store)) + } + if got, _ := cache.get("a"); got != 3 { + t.Errorf("expected the overwritten value, got %d", got) + } +} + +func contains(values []string, want string) bool { + for _, v := range values { + if v == want { + return true + } + } + return false +} + +// TestParseQueryResultKeepsLargeIntegersExact guards against float64 decoding, +// which would render a 4239559 contributor count as 4.239559e+06 by the time +// the model reads it. +func TestParseQueryResultKeepsLargeIntegersExact(t *testing.T) { + raw := `{"schema":{"fields":[{"name":"total_contributors","type":"integer"}],"primaryKey":[]}, + "data":[{"total_contributors":4239559}]}` + + result, err := parseQueryResult(raw, "") + if err != nil { + t.Fatalf("parseQueryResult failed: %v", err) + } + + encoded, err := json.Marshal(result.Data[0]) + if err != nil { + t.Fatalf("failed to re-encode the row: %v", err) + } + if !strings.Contains(string(encoded), "4239559") { + t.Errorf("expected the exact integer, got %s", encoded) + } + if strings.Contains(string(encoded), "e+") { + t.Errorf("expected no scientific notation, got %s", encoded) + } +} diff --git a/internal/dbtsl/dimensionvalues.go b/internal/dbtsl/dimensionvalues.go new file mode 100644 index 0000000..931968d --- /dev/null +++ b/internal/dbtsl/dimensionvalues.go @@ -0,0 +1,189 @@ +// Copyright The Linux Foundation and contributors. +// SPDX-License-Identifier: MIT + +package dbtsl + +import ( + "context" + "fmt" + "regexp" + "sort" + "strings" +) + +// Dimension value discovery. +// +// A filter naming a real dimension but a wrong literal is not an error: the +// query succeeds and returns zero rows, which reads as "no such data" rather +// than "no such spelling". Observed live: lf_region 'APAC' when the value is +// 'Asia Pacific', and country_name 'Vietnam' when it is 'Viet Nam', each +// costing several wrong-but-successful queries. Listing the values is the only +// fix that generalises past the literals we happen to have seen. + +// DimensionValuesMaxLimit caps how many values a single call can return. +const DimensionValuesMaxLimit = 500 + +// safeDimensionName matches a qualified dimension name. +// +// Qualified names are entity__field, word characters throughout. The name is +// interpolated into a MetricFlow filter expression, so anything else is +// rejected rather than escaped. +var safeDimensionName = regexp.MustCompile(`^[A-Za-z0-9_]+$`) + +// UnknownDimensionError is returned when a dimension is not available to the +// given metrics, or is not a name this client will interpolate. +type UnknownDimensionError struct { + Message string +} + +func (e *UnknownDimensionError) Error() string { + return e.Message +} + +// DimensionValues is the outcome of a dimension value lookup. +type DimensionValues struct { + Dimension string `json:"dimension"` + Values []string `json:"values"` + ValueCount int `json:"value_count"` + // Truncated reports that the list hit the limit, so it is a sample rather + // than the dimension's full domain. Narrow it with a search. + Truncated bool `json:"truncated"` +} + +// escapeSQLLiteral escapes value for use inside a single-quoted SQL string. +func escapeSQLLiteral(value string) string { + return strings.ReplaceAll(strings.ReplaceAll(value, `\`, `\\`), `'`, `''`) +} + +// FetchDimensionValues returns the distinct values of a dimension, so a caller +// can write a filter that matches something. +// +// metricNames gates access. A dimension-only query bypasses the metric +// allowlist completely, so without this check any dimension in the semantic +// layer, including PII-bearing ones no allowlisted metric exposes, would be +// dumpable. Callers already hold the metric from list_metrics, so requiring it +// costs nothing. +// +// The query itself passes no metrics: values are then the dimension's full +// domain rather than only those appearing for one metric, and it still returns +// in about a second on a 250-value dimension. +func (c *Client) FetchDimensionValues(ctx context.Context, dimension string, metricNames []string, search string, limit int) (*DimensionValues, error) { + dimension = strings.TrimSpace(dimension) + if !safeDimensionName.MatchString(dimension) { + return nil, &UnknownDimensionError{Message: fmt.Sprintf( + "Invalid dimension name %q. Expected a qualified_name from get_dimensions, e.g. 'country__lf_region'.", + dimension, + )} + } + + if disallowed := ValidateMetrics(metricNames); len(disallowed) > 0 { + return nil, &UnknownDimensionError{Message: fmt.Sprintf( + "Metrics not in allowlist: %s.", strings.Join(disallowed, ", "), + )} + } + + available, err := c.FetchDimensions(ctx, metricNames) + if err != nil { + return nil, err + } + found := false + for _, d := range available { + if d.Name == dimension { + found = true + break + } + } + if !found { + return nil, &UnknownDimensionError{Message: fmt.Sprintf( + "Dimension %q is not available to %s. Use get_dimensions to list them.", + dimension, strings.Join(metricNames, ", "), + )} + } + + if limit < 1 { + limit = 1 + } + if limit > DimensionValuesMaxLimit { + limit = DimensionValuesMaxLimit + } + + search = strings.TrimSpace(search) + cacheKey := fmt.Sprintf("%s|%s|%d", dimension, search, limit) + if cached, ok := c.dimensionValuesCache.get(cacheKey); ok { + return newDimensionValues(dimension, cached, limit), nil + } + + args := QueryArgs{ + Metrics: nil, + GroupBy: []string{dimension}, + Limit: limit, + } + if search != "" { + args.Where = []string{fmt.Sprintf( + "{{ Dimension('%s') }} ILIKE '%%%s%%'", dimension, escapeSQLLiteral(search), + )} + } + + result, err := c.Query(ctx, args) + if err != nil { + return nil, err + } + + values := distinctValues(result, dimension) + c.dimensionValuesCache.put(cacheKey, values) + return newDimensionValues(dimension, values, limit), nil +} + +// distinctValues pulls the sorted, deduplicated, non-null values out of a +// dimension-only query result. +// +// NULL is not a filterable literal, so it is noise here. +func distinctValues(result *QueryResult, dimension string) []string { + column := dimension + if len(result.Columns) > 0 { + column = result.Columns[0] + } + + seen := make(map[string]struct{}, len(result.Data)) + values := make([]string, 0, len(result.Data)) + for _, row := range result.Data { + raw, ok := row[column] + if !ok || raw == nil { + continue + } + value := fmt.Sprintf("%v", raw) + if _, dup := seen[value]; dup { + continue + } + seen[value] = struct{}{} + values = append(values, value) + } + sort.Strings(values) + return values +} + +func newDimensionValues(dimension string, values []string, limit int) *DimensionValues { + return &DimensionValues{ + Dimension: dimension, + Values: values, + ValueCount: len(values), + Truncated: len(values) >= limit, + } +} + +// NoDimensionValuesDetail is the message for a lookup that matched nothing. +// +// The stored spelling is often not the everyday one, so the caller is pointed +// at that rather than left to conclude the data is empty. +func NoDimensionValuesDetail(dimension, search string) string { + if search == "" { + return fmt.Sprintf("Dimension %q has no non-null values.", dimension) + } + return fmt.Sprintf( + "No values of %q matched %q. The search is a plain substring, so try a "+ + "shorter fragment - country names use their ISO spelling "+ + "('Viet Nam', 'Korea, Republic of'), which often differs from the "+ + "everyday one.", + dimension, search, + ) +} diff --git a/internal/dbtsl/metadata.go b/internal/dbtsl/metadata.go new file mode 100644 index 0000000..c92f5b3 --- /dev/null +++ b/internal/dbtsl/metadata.go @@ -0,0 +1,281 @@ +// Copyright The Linux Foundation and contributors. +// SPDX-License-Identifier: MIT + +package dbtsl + +import ( + "context" + "sort" + "strings" +) + +// inlineDimensionsThreshold is the largest metric count for which dimensions +// are inlined into the metric list. Above it the caller is expected to narrow +// the search first, then ask for dimensions explicitly. +const inlineDimensionsThreshold = 15 + +const gqlMetrics = ` +query GetMetrics($environmentId: BigInt!, $search: String) { + metricsPaginated(environmentId: $environmentId, search: $search) { + items { + name + label + description + type + } + } +} +` + +const gqlMetricsWithRelated = ` +query GetMetricsWithRelated($environmentId: BigInt!, $search: String) { + metricsPaginated(environmentId: $environmentId, search: $search) { + items { + name + label + description + type + dimensions { + name + } + entities { + name + } + } + } +} +` + +const gqlDimensions = ` +query GetDimensions($environmentId: BigInt!, $metrics: [MetricInput!]!) { + dimensionsPaginated(environmentId: $environmentId, metrics: $metrics) { + items { + name + type + description + label + queryableGranularities + } + } +} +` + +// MetricInfo describes one metric in the semantic layer. +type MetricInfo struct { + Name string `json:"name"` + Label string `json:"label"` + Description string `json:"description,omitempty"` + Type string `json:"type"` + Dimensions []string `json:"dimensions,omitempty"` + Entities []string `json:"entities,omitempty"` +} + +// DimensionInfo describes one dimension available to a set of metrics. +type DimensionInfo struct { + // Name is the qualified name, e.g. "country__lf_region". + Name string `json:"name"` + Type string `json:"type"` + Description string `json:"description,omitempty"` + Label string `json:"label,omitempty"` + QueryableTimeGranularities []string `json:"queryable_time_granularities"` +} + +// metricsResponse decodes the metricsPaginated GraphQL shape. +type metricsResponse struct { + MetricsPaginated struct { + Items []struct { + Name string `json:"name"` + Label string `json:"label"` + Description string `json:"description"` + Type string `json:"type"` + Dimensions []struct { + Name string `json:"name"` + } `json:"dimensions"` + Entities []struct { + Name string `json:"name"` + } `json:"entities"` + } `json:"items"` + } `json:"metricsPaginated"` +} + +// dimensionsResponse decodes the dimensionsPaginated GraphQL shape. +type dimensionsResponse struct { + DimensionsPaginated struct { + Items []struct { + Name string `json:"name"` + Type string `json:"type"` + Description string `json:"description"` + Label string `json:"label"` + QueryableGranularities []string `json:"queryableGranularities"` + } `json:"items"` + } `json:"dimensionsPaginated"` +} + +// FetchAllowedMetrics returns allowlist-filtered metrics, with dimensions +// inlined when the result is small enough to be worth it. +// +// Only searched results are cached. The no-search case is a single cheap call +// that returns every metric without dimensions. +func (c *Client) FetchAllowedMetrics(ctx context.Context, search string) ([]MetricInfo, error) { + search = strings.TrimSpace(search) + + if search != "" { + if cached, ok := c.metricsCache.get(search); ok { + return cached, nil + } + } + + all, err := c.fetchMetricsRaw(ctx, search, false) + if err != nil { + return nil, err + } + allowed := filterAllowed(all) + matchedByWord := false + + // The Semantic Layer matches search as a single phrase and only in the + // exact form given, so both a natural-language term like "contributor + // organization country" and a bare plural like "contributions" come back + // empty even though "contributor" alone finds two metrics. Rather than + // leave the caller at a dead end, retry once matching any single word, or + // its singular, against the metric name and description. This only runs + // when the first search came back empty, so it cannot change a result that + // already worked. + if len(allowed) == 0 && search != "" { + every, err := c.fetchMetricsRaw(ctx, "", false) + if err != nil { + return nil, err + } + allowed = matchAnyWord(every, search) + matchedByWord = true + } + + if len(allowed) > 0 && len(allowed) <= inlineDimensionsThreshold { + // Re-fetch with dimensions. The per-word path must not send search + // upstream, since it already returned nothing, so fetch everything and + // re-apply the same word matching. + if matchedByWord { + every, err := c.fetchMetricsRaw(ctx, "", true) + if err != nil { + return nil, err + } + allowed = matchAnyWord(every, search) + } else { + withDims, err := c.fetchMetricsRaw(ctx, search, true) + if err != nil { + return nil, err + } + allowed = filterAllowed(withDims) + } + } + + if search != "" { + c.metricsCache.put(search, allowed) + } + return allowed, nil +} + +func filterAllowed(metrics []MetricInfo) []MetricInfo { + var allowed []MetricInfo + for _, m := range metrics { + if IsAllowedMetric(m.Name) { + allowed = append(allowed, m) + } + } + return allowed +} + +// fetchMetricsRaw fetches metrics from the semantic layer, uncached and +// unfiltered. When includeDimensions is set each metric carries its available +// dimension names, at the cost of an extra GraphQL field. +func (c *Client) fetchMetricsRaw(ctx context.Context, search string, includeDimensions bool) ([]MetricInfo, error) { + variables := map[string]any{} + if search != "" { + variables["search"] = search + } + + query := gqlMetrics + if includeDimensions { + query = gqlMetricsWithRelated + } + + var resp metricsResponse + if err := c.graphqlRequest(ctx, query, variables, &resp); err != nil { + return nil, err + } + + metrics := make([]MetricInfo, 0, len(resp.MetricsPaginated.Items)) + for _, item := range resp.MetricsPaginated.Items { + metric := MetricInfo{ + Name: item.Name, + Label: item.Label, + Description: item.Description, + Type: item.Type, + } + for _, d := range item.Dimensions { + metric.Dimensions = append(metric.Dimensions, d.Name) + } + for _, e := range item.Entities { + metric.Entities = append(metric.Entities, e.Name) + } + metrics = append(metrics, metric) + } + return metrics, nil +} + +// FetchDimensions returns the dimensions available to a set of metrics. +// +// Results are cached under the deduplicated, sorted metric names. +func (c *Client) FetchDimensions(ctx context.Context, metricNames []string) ([]DimensionInfo, error) { + normalized := normalizeMetricNames(metricNames) + cacheKey := strings.Join(normalized, ",") + if cached, ok := c.dimensionsCache.get(cacheKey); ok { + return cached, nil + } + + metricInputs := make([]map[string]string, 0, len(normalized)) + for _, name := range normalized { + metricInputs = append(metricInputs, map[string]string{"name": name}) + } + + var resp dimensionsResponse + if err := c.graphqlRequest(ctx, gqlDimensions, map[string]any{"metrics": metricInputs}, &resp); err != nil { + return nil, err + } + + dimensions := make([]DimensionInfo, 0, len(resp.DimensionsPaginated.Items)) + for _, item := range resp.DimensionsPaginated.Items { + granularities := item.QueryableGranularities + if granularities == nil { + granularities = []string{} + } + dimensions = append(dimensions, DimensionInfo{ + Name: item.Name, + Type: item.Type, + Description: item.Description, + Label: item.Label, + QueryableTimeGranularities: granularities, + }) + } + + c.dimensionsCache.put(cacheKey, dimensions) + return dimensions, nil +} + +// normalizeMetricNames trims, drops empties, deduplicates and sorts. +func normalizeMetricNames(names []string) []string { + seen := make(map[string]struct{}, len(names)) + var normalized []string + for _, name := range names { + trimmed := strings.TrimSpace(name) + if trimmed == "" { + continue + } + if _, dup := seen[trimmed]; dup { + continue + } + seen[trimmed] = struct{}{} + normalized = append(normalized, trimmed) + } + sort.Strings(normalized) + return normalized +} diff --git a/internal/dbtsl/parity_live_test.go b/internal/dbtsl/parity_live_test.go new file mode 100644 index 0000000..bd69a45 --- /dev/null +++ b/internal/dbtsl/parity_live_test.go @@ -0,0 +1,324 @@ +// Copyright The Linux Foundation and contributors. +// SPDX-License-Identifier: MIT + +//go:build parity + +// Live parity harness for the dbt Semantic Layer client. +// +// This is excluded from a normal build and test run: it talks to the real +// semantic layer and needs credentials. It exists because this client's query +// path is written from scratch against the GraphQL API, while both reference +// implementations (lfx-lens and dbt Labs' dbt-mcp) execute queries over Arrow +// Flight through the Python SDK. Unit tests against a stub cannot tell us the +// GraphQL request shapes are right, only that they are consistent. +// +// Run it with credentials from the lfx-lens .env: +// +// set -a && source ../lfx-lens/.env && set +a +// go test -tags parity -v -run TestLive ./internal/dbtsl/ +package dbtsl + +import ( + "context" + "os" + "strings" + "testing" + "time" +) + +func liveClient(t *testing.T) *Client { + t.Helper() + + cfg := Config{ + Host: os.Getenv("DBT_SL_HOST"), + EnvironmentID: os.Getenv("DBT_SEMANTIC_ENV_ID"), + Token: os.Getenv("DBT_SEMANTIC_SERVICE_TOKEN"), + } + if cfg.Host == "" || cfg.EnvironmentID == "" || cfg.Token == "" { + t.Skip("set DBT_SL_HOST, DBT_SEMANTIC_ENV_ID and DBT_SEMANTIC_SERVICE_TOKEN to run the parity harness") + } + + client, err := NewClient(cfg) + if err != nil { + t.Fatalf("NewClient failed: %v", err) + } + return client +} + +func liveContext(t *testing.T) (context.Context, context.CancelFunc) { + t.Helper() + return context.WithTimeout(context.Background(), 2*time.Minute) +} + +// TestLiveListMetrics checks the allowlist actually intersects what the +// semantic layer exposes. A rename upstream would show up here as a metric we +// allow but can no longer reach. +func TestLiveListMetrics(t *testing.T) { + client := liveClient(t) + ctx, cancel := liveContext(t) + defer cancel() + + metrics, err := client.FetchAllowedMetrics(ctx, "") + if err != nil { + t.Fatalf("FetchAllowedMetrics failed: %v", err) + } + t.Logf("allowlisted metrics reachable: %d of %d", len(metrics), len(AllowedMetricNames())) + if len(metrics) == 0 { + t.Fatal("no allowlisted metric is reachable") + } + + reachable := make(map[string]bool, len(metrics)) + for _, m := range metrics { + reachable[m.Name] = true + } + var missing []string + for _, name := range AllowedMetricNames() { + if !reachable[name] { + missing = append(missing, name) + } + } + if len(missing) > 0 { + t.Logf("allowlisted but not reachable upstream: %v", missing) + } +} + +// TestLiveSearchFallbacks covers the two rescues that keep a search from +// dead-ending: the singular stem and the per-word retry. +func TestLiveSearchFallbacks(t *testing.T) { + client := liveClient(t) + + for _, tc := range []struct { + name string + search string + }{ + {"exact singular", "contributor"}, + {"plural needs the stem", "contributions"}, + {"natural language needs the per-word retry", "contributor organization country"}, + } { + t.Run(tc.name, func(t *testing.T) { + ctx, cancel := liveContext(t) + defer cancel() + + metrics, err := client.FetchAllowedMetrics(ctx, tc.search) + if err != nil { + t.Fatalf("FetchAllowedMetrics(%q) failed: %v", tc.search, err) + } + if len(metrics) == 0 { + t.Errorf("search %q returned nothing, which is the dead end this is meant to prevent", tc.search) + } + names := make([]string, 0, len(metrics)) + for _, m := range metrics { + names = append(names, m.Name) + } + t.Logf("search %q -> %v", tc.search, names) + }) + } +} + +// TestLiveEverySearchableTopicReturnsMetrics is the live half of the topic +// word test. The unit test only proves a topic matches a name we ship; this +// proves the search actually returns something. +func TestLiveEverySearchableTopicReturnsMetrics(t *testing.T) { + client := liveClient(t) + + for _, topic := range SearchableTopics() { + ctx, cancel := liveContext(t) + metrics, err := client.FetchAllowedMetrics(ctx, topic) + cancel() + if err != nil { + t.Errorf("topic %q failed: %v", topic, err) + continue + } + if len(metrics) == 0 { + t.Errorf("topic %q returned no metrics, so the guidance sends callers to a dead end", topic) + } + } +} + +func TestLiveDimensions(t *testing.T) { + client := liveClient(t) + ctx, cancel := liveContext(t) + defer cancel() + + dimensions, err := client.FetchDimensions(ctx, []string{"total_contributors"}) + if err != nil { + t.Fatalf("FetchDimensions failed: %v", err) + } + if len(dimensions) == 0 { + t.Fatal("expected dimensions for total_contributors") + } + t.Logf("total_contributors exposes %d dimensions", len(dimensions)) + + var hasRegion bool + for _, d := range dimensions { + if d.Name == "country__lf_region" { + hasRegion = true + } + } + if !hasRegion { + t.Error("expected country__lf_region among the dimensions, which the regional lens work added") + } +} + +// TestLiveDimensionValuesRegion is the motivating case for the whole epic: +// the stored value is 'Asia Pacific', and a caller guessing 'APAC' gets zero +// rows rather than an error. +func TestLiveDimensionValuesRegion(t *testing.T) { + client := liveClient(t) + ctx, cancel := liveContext(t) + defer cancel() + + start := time.Now() + values, err := client.FetchDimensionValues(ctx, "country__lf_region", []string{"total_contributors"}, "", 100) + if err != nil { + t.Fatalf("FetchDimensionValues failed: %v", err) + } + t.Logf("country__lf_region -> %d values in %s: %v", values.ValueCount, time.Since(start).Round(time.Millisecond), values.Values) + + if !containsValue(values.Values, "Asia Pacific") { + t.Errorf("expected 'Asia Pacific' among the region values, got %v", values.Values) + } + if containsValue(values.Values, "APAC") { + t.Error("'APAC' is not a stored value, so finding it means the query is wrong") + } +} + +// TestLiveDimensionValuesCountrySearch proves the ISO spelling comes back for +// the everyday one, which is the fix for the Vietnam question. +func TestLiveDimensionValuesCountrySearch(t *testing.T) { + client := liveClient(t) + ctx, cancel := liveContext(t) + defer cancel() + + values, err := client.FetchDimensionValues(ctx, "country__country_name", []string{"total_contributors"}, "viet", 100) + if err != nil { + t.Fatalf("FetchDimensionValues failed: %v", err) + } + t.Logf("country__country_name search 'viet' -> %v", values.Values) + + if !containsValue(values.Values, "Viet Nam") { + t.Errorf("expected the ISO spelling 'Viet Nam', got %v", values.Values) + } +} + +// TestLiveDimensionValuesGate confirms the metric gate holds against the real +// API, not just the stub. Without it, any dimension in the semantic layer +// would be enumerable, because a dimension-only query never consults the +// metric allowlist. +func TestLiveDimensionValuesGate(t *testing.T) { + client := liveClient(t) + ctx, cancel := liveContext(t) + defer cancel() + + if _, err := client.FetchDimensionValues(ctx, "user__email", []string{"total_contributors"}, "", 10); err == nil { + t.Fatal("expected a dimension outside the metric to be rejected") + } +} + +// TestLiveQuery exercises the createQuery and poll path, which is the part +// with no reference implementation to copy. +func TestLiveQuery(t *testing.T) { + client := liveClient(t) + ctx, cancel := liveContext(t) + defer cancel() + + start := time.Now() + result, err := client.Query(ctx, QueryArgs{ + Metrics: []string{"total_contributors"}, + GroupBy: []string{"country__lf_region"}, + OrderBy: []string{"-total_contributors"}, + Limit: 10, + }) + if err != nil { + t.Fatalf("Query failed: %v", err) + } + t.Logf("query returned %d rows in %s", result.RowCount, time.Since(start).Round(time.Millisecond)) + t.Logf("columns: %v", result.Columns) + for _, row := range result.Data { + t.Logf(" %v", row) + } + + if result.RowCount == 0 { + t.Error("expected rows for contributors by region") + } + if len(result.Columns) != 2 { + t.Errorf("expected the group by and the metric as columns, got %v", result.Columns) + } + for _, row := range result.Data { + if _, present := row["index"]; present { + t.Error("the synthetic row index leaked into the result") + break + } + } +} + +// TestLiveQueryWithTimeGrain covers the translation the GraphQL API forces: +// callers write metric_time__year, the API wants a separate grain field. If +// this fails, every time series query is broken. +func TestLiveQueryWithTimeGrain(t *testing.T) { + client := liveClient(t) + ctx, cancel := liveContext(t) + defer cancel() + + result, err := client.Query(ctx, QueryArgs{ + Metrics: []string{"total_contributors"}, + GroupBy: []string{"metric_time__year"}, + Limit: 5, + }) + if err != nil { + t.Fatalf("time grain query failed: %v", err) + } + t.Logf("columns: %v, rows: %d", result.Columns, result.RowCount) + for _, row := range result.Data { + t.Logf(" %v", row) + } + if result.RowCount == 0 { + t.Error("expected at least one year of contributors") + } +} + +// TestLiveQueryWithWhereFilter checks a MetricFlow filter survives the trip +// through the GraphQL WhereInput. +func TestLiveQueryWithWhereFilter(t *testing.T) { + client := liveClient(t) + ctx, cancel := liveContext(t) + defer cancel() + + result, err := client.Query(ctx, QueryArgs{ + Metrics: []string{"total_contributors"}, + GroupBy: []string{"country__country_name"}, + Where: []string{"{{ Dimension('country__lf_region') }} = 'Asia Pacific'"}, + Limit: 10, + }) + if err != nil { + t.Fatalf("filtered query failed: %v", err) + } + t.Logf("Asia Pacific -> %d rows: %v", result.RowCount, result.Data) + if result.RowCount == 0 { + t.Error("expected rows for Asia Pacific, so either the filter or the literal is wrong") + } +} + +// TestLiveQueryRejectsAnUnknownMetric confirms an application-level failure +// arrives as QueryFailedError with the upstream reason intact, rather than as +// a transport error. +func TestLiveQueryRejectsAnUnknownMetric(t *testing.T) { + client := liveClient(t) + ctx, cancel := liveContext(t) + defer cancel() + + _, err := client.Query(ctx, QueryArgs{Metrics: []string{"definitely_not_a_metric"}, Limit: 1}) + if err == nil { + t.Fatal("expected an unknown metric to fail") + } + t.Logf("unknown metric error: %v", err) +} + +func containsValue(values []string, want string) bool { + for _, v := range values { + if strings.EqualFold(v, want) { + return true + } + } + return false +} diff --git a/internal/dbtsl/query.go b/internal/dbtsl/query.go new file mode 100644 index 0000000..392688b --- /dev/null +++ b/internal/dbtsl/query.go @@ -0,0 +1,351 @@ +// Copyright The Linux Foundation and contributors. +// SPDX-License-Identifier: MIT + +package dbtsl + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "time" +) + +// The published GraphQL docs name this argument "order", but the deployed +// schema calls it "orderBy". Introspection is the source of truth here. +const gqlCreateQuery = ` +mutation CreateQuery($environmentId: BigInt!, $metrics: [MetricInput!], $groupBy: [GroupByInput!], $where: [WhereInput!], $orderBy: [OrderByInput!], $limit: Int) { + createQuery(environmentId: $environmentId, metrics: $metrics, groupBy: $groupBy, where: $where, orderBy: $orderBy, limit: $limit) { + queryId + } +} +` + +const gqlQueryResult = ` +query GetQueryResult($environmentId: BigInt!, $queryId: String!) { + query(environmentId: $environmentId, queryId: $queryId) { + status + error + sql + jsonResult(encoded: false, orient: TABLE) + } +} +` + +// Query polling cadence. The Semantic Layer compiles and runs warehouse SQL, +// so the first result is rarely ready immediately. The interval backs off so a +// slow query does not generate a poll storm, and the overall bound comes from +// the caller's context. +const ( + pollInitialInterval = 250 * time.Millisecond + pollMaxInterval = 2 * time.Second + pollBackoffFactor = 1.5 +) + +// Query status values returned by the Semantic Layer. Anything else means the +// query is still in flight. +const ( + statusSuccessful = "SUCCESSFUL" + statusFailed = "FAILED" +) + +// timeGranularities are the TimeGranularity enum values a group_by name may +// carry as a trailing __suffix. +// +// Callers write time grains the way the dbt SDK accepts them, +// "metric_time__month", but the GraphQL API takes the grain as a separate +// field, {name: "metric_time", grain: MONTH}. Splitting on a known +// granularity is what bridges the two. A suffix that is not a granularity, +// such as the "lf_region" in "country__lf_region", is left alone. +var timeGranularities = map[string]string{ + "nanosecond": "NANOSECOND", + "microsecond": "MICROSECOND", + "millisecond": "MILLISECOND", + "second": "SECOND", + "minute": "MINUTE", + "hour": "HOUR", + "day": "DAY", + "week": "WEEK", + "month": "MONTH", + "quarter": "QUARTER", + "year": "YEAR", +} + +// QueryArgs describes a metric query. +type QueryArgs struct { + // Metrics may be empty, which asks for a dimension's own domain rather + // than the values co-occurring with some metric. + Metrics []string + GroupBy []string + // Where holds MetricFlow filter expressions, e.g. + // "{{ Dimension('country__lf_region') }} = 'Asia Pacific'". + Where []string + // OrderBy holds field names, prefixed with "-" for descending. + OrderBy []string + Limit int +} + +// QueryResult is a completed query's tabular result. +type QueryResult struct { + Columns []string `json:"columns"` + Data []map[string]any `json:"data"` + RowCount int `json:"row_count"` + // CompiledSQL is the warehouse SQL the Semantic Layer generated, when the + // API returned it. + CompiledSQL string `json:"compiled_sql,omitempty"` +} + +// QueryFailedError is returned when the Semantic Layer rejects or fails a +// query. It is an application-level error, not a transport failure, so the +// caller should surface the message rather than retry. +type QueryFailedError struct { + Message string +} + +func (e *QueryFailedError) Error() string { + return e.Message +} + +type createQueryResponse struct { + CreateQuery struct { + QueryID string `json:"queryId"` + } `json:"createQuery"` +} + +type queryResultResponse struct { + Query struct { + Status string `json:"status"` + Error string `json:"error"` + SQL string `json:"sql"` + JSONResult string `json:"jsonResult"` + } `json:"query"` +} + +// Query runs a metric query and returns its rows. +// +// It submits the query, then polls until the Semantic Layer reports it +// successful or failed. Results come back as JSON rather than Arrow, which +// keeps this client free of an Arrow and gRPC dependency. +func (c *Client) Query(ctx context.Context, args QueryArgs) (*QueryResult, error) { + queryID, err := c.createQuery(ctx, args) + if err != nil { + return nil, err + } + + interval := pollInitialInterval + for { + result, err := c.pollQuery(ctx, queryID) + if err != nil { + return nil, err + } + + switch result.Query.Status { + case statusSuccessful: + return parseQueryResult(result.Query.JSONResult, result.Query.SQL) + case statusFailed: + message := strings.TrimSpace(result.Query.Error) + if message == "" { + message = "the semantic layer reported the query failed but gave no reason" + } + return nil, &QueryFailedError{Message: message} + } + + select { + case <-ctx.Done(): + return nil, fmt.Errorf("semantic layer query did not finish in time: %w", ctx.Err()) + case <-time.After(interval): + } + + if interval = time.Duration(float64(interval) * pollBackoffFactor); interval > pollMaxInterval { + interval = pollMaxInterval + } + } +} + +func (c *Client) createQuery(ctx context.Context, args QueryArgs) (string, error) { + metricInputs := make([]map[string]any, 0, len(args.Metrics)) + for _, name := range args.Metrics { + if trimmed := strings.TrimSpace(name); trimmed != "" { + metricInputs = append(metricInputs, map[string]any{"name": trimmed}) + } + } + + variables := map[string]any{"metrics": metricInputs} + + if groupBy := buildGroupByInputs(args.GroupBy); len(groupBy) > 0 { + variables["groupBy"] = groupBy + } + if where := buildWhereInputs(args.Where); len(where) > 0 { + variables["where"] = where + } + if order := buildOrderByInputs(args.OrderBy, args.Metrics); len(order) > 0 { + variables["orderBy"] = order + } + if args.Limit > 0 { + variables["limit"] = args.Limit + } + + var resp createQueryResponse + if err := c.graphqlRequest(ctx, gqlCreateQuery, variables, &resp); err != nil { + return "", err + } + if resp.CreateQuery.QueryID == "" { + return "", fmt.Errorf("semantic layer accepted the query but returned no query ID") + } + return resp.CreateQuery.QueryID, nil +} + +func (c *Client) pollQuery(ctx context.Context, queryID string) (*queryResultResponse, error) { + var resp queryResultResponse + if err := c.graphqlRequest(ctx, gqlQueryResult, map[string]any{"queryId": queryID}, &resp); err != nil { + return nil, err + } + return &resp, nil +} + +// buildGroupByInputs converts group_by names into GroupByInput values, +// splitting a trailing time granularity into its own field. +func buildGroupByInputs(names []string) []map[string]any { + inputs := make([]map[string]any, 0, len(names)) + for _, raw := range names { + name := strings.TrimSpace(raw) + if name == "" { + continue + } + input := map[string]any{"name": name} + if base, grain, ok := splitTimeGrain(name); ok { + input["name"] = base + input["grain"] = grain + } + inputs = append(inputs, input) + } + return inputs +} + +// splitTimeGrain splits "metric_time__month" into "metric_time" and "MONTH". +// It reports false when the trailing segment is not a time granularity. +func splitTimeGrain(name string) (base, grain string, ok bool) { + idx := strings.LastIndex(name, "__") + if idx <= 0 { + return "", "", false + } + suffix := strings.ToLower(name[idx+2:]) + grain, isGrain := timeGranularities[suffix] + if !isGrain { + return "", "", false + } + return name[:idx], grain, true +} + +func buildWhereInputs(clauses []string) []map[string]any { + inputs := make([]map[string]any, 0, len(clauses)) + for _, clause := range clauses { + if trimmed := strings.TrimSpace(clause); trimmed != "" { + inputs = append(inputs, map[string]any{"sql": trimmed}) + } + } + return inputs +} + +// buildOrderByInputs converts order_by names into OrderByInput values. +// +// A name is ordered as a metric when it is one of the query's metrics, and as +// a group_by otherwise, which is how the dbt SDK resolves the same ambiguity. +// A leading "-" means descending. +func buildOrderByInputs(names, metrics []string) []map[string]any { + metricSet := make(map[string]struct{}, len(metrics)) + for _, m := range metrics { + metricSet[strings.TrimSpace(m)] = struct{}{} + } + + inputs := make([]map[string]any, 0, len(names)) + for _, raw := range names { + name := strings.TrimSpace(raw) + if name == "" { + continue + } + descending := strings.HasPrefix(name, "-") + name = strings.TrimPrefix(name, "-") + if name == "" { + continue + } + + input := map[string]any{"descending": descending} + if _, isMetric := metricSet[name]; isMetric { + input["metric"] = map[string]any{"name": name} + } else { + groupBy := map[string]any{"name": name} + if base, grain, ok := splitTimeGrain(name); ok { + groupBy["name"] = base + groupBy["grain"] = grain + } + input["groupBy"] = groupBy + } + inputs = append(inputs, input) + } + return inputs +} + +// tableResult is the pandas "table" JSON orientation the API returns. +type tableResult struct { + Schema struct { + Fields []struct { + Name string `json:"name"` + Type string `json:"type"` + } `json:"fields"` + PrimaryKey []string `json:"primaryKey"` + } `json:"schema"` + Data []map[string]any `json:"data"` +} + +// parseQueryResult turns the API's JSON result into columns and rows. +// +// The "table" orientation carries a synthetic row index in both the schema and +// every row. It is an artifact of the serialisation rather than query output, +// so it is stripped. +func parseQueryResult(raw, compiledSQL string) (*QueryResult, error) { + if strings.TrimSpace(raw) == "" { + return &QueryResult{Columns: []string{}, Data: []map[string]any{}, CompiledSQL: compiledSQL}, nil + } + + // Decode numbers as json.Number rather than float64. Metric values are + // often large counts, and float64 round-trips 4239559 back out as + // 4.239559e+06, which is what the model would then read. + decoder := json.NewDecoder(strings.NewReader(raw)) + decoder.UseNumber() + + var table tableResult + if err := decoder.Decode(&table); err != nil { + return nil, fmt.Errorf("failed to decode semantic layer query result: %w", err) + } + + indexFields := make(map[string]struct{}, len(table.Schema.PrimaryKey)) + for _, key := range table.Schema.PrimaryKey { + if key == "index" { + indexFields[key] = struct{}{} + } + } + + columns := make([]string, 0, len(table.Schema.Fields)) + for _, field := range table.Schema.Fields { + if _, skip := indexFields[field.Name]; skip { + continue + } + columns = append(columns, field.Name) + } + + rows := make([]map[string]any, 0, len(table.Data)) + for _, row := range table.Data { + for name := range indexFields { + delete(row, name) + } + rows = append(rows, row) + } + + return &QueryResult{ + Columns: columns, + Data: rows, + RowCount: len(rows), + CompiledSQL: compiledSQL, + }, nil +} diff --git a/internal/dbtsl/search.go b/internal/dbtsl/search.go new file mode 100644 index 0000000..6e4e18d --- /dev/null +++ b/internal/dbtsl/search.go @@ -0,0 +1,58 @@ +// Copyright The Linux Foundation and contributors. +// SPDX-License-Identifier: MIT + +package dbtsl + +import "strings" + +// singularVariants returns word plus a naive singular form. +// +// Metric names and descriptions are written in the singular ("contribution", +// "membership"), so a caller searching the plural matches nothing, and a +// plural is the natural thing to type for a domain. Substring matching cannot +// rescue it either, since the plural is the longer string. +func singularVariants(word string) []string { + variants := []string{word} + for _, rule := range []struct{ suffix, stem string }{ + {"ies", "y"}, + {"ses", "s"}, + {"s", ""}, + } { + if strings.HasSuffix(word, rule.suffix) && len(word)-len(rule.suffix) >= 3 { + variants = append(variants, strings.TrimSuffix(word, rule.suffix)+rule.stem) + break + } + } + return variants +} + +// matchAnyWord returns the allowlisted metrics whose name or description +// contains any word of search, or that word's singular form. +func matchAnyWord(metrics []MetricInfo, search string) []MetricInfo { + var words []string + for _, token := range strings.Fields(search) { + w := strings.ToLower(strings.TrimSpace(token)) + if w == "" { + continue + } + words = append(words, singularVariants(w)...) + } + if len(words) == 0 { + return nil + } + + var matched []MetricInfo + for _, m := range metrics { + if !IsAllowedMetric(m.Name) { + continue + } + haystack := strings.ToLower(m.Name + " " + m.Description) + for _, w := range words { + if strings.Contains(haystack, w) { + matched = append(matched, m) + break + } + } + } + return matched +} diff --git a/internal/dbtsl/similarity.go b/internal/dbtsl/similarity.go new file mode 100644 index 0000000..50f81df --- /dev/null +++ b/internal/dbtsl/similarity.go @@ -0,0 +1,73 @@ +// Copyright The Linux Foundation and contributors. +// SPDX-License-Identifier: MIT + +package dbtsl + +// similarityRatio returns the Ratcliff/Obershelp similarity of a and b, in +// the range 0 to 1. +// +// This is the algorithm behind Python's difflib.SequenceMatcher.ratio(), which +// the lfx-lens implementation used for its metric suggestion fallback. It is +// reproduced rather than approximated with an edit distance so that the +// suggestions a caller sees do not change as part of the port. +// +// difflib's "autojunk" heuristic is not reproduced: it only engages on +// sequences of 200 elements or more, and metric names are far shorter. +func similarityRatio(a, b string) float64 { + ra, rb := []rune(a), []rune(b) + total := len(ra) + len(rb) + if total == 0 { + return 1 + } + return 2 * float64(matchingRunes(ra, rb)) / float64(total) +} + +// matchingRunes counts the runes a and b have in common, by finding the +// longest common contiguous run and recursing into the segments on either +// side of it. +func matchingRunes(a, b []rune) int { + if len(a) == 0 || len(b) == 0 { + return 0 + } + + aStart, bStart, length := longestCommonRun(a, b) + if length == 0 { + return 0 + } + + return length + + matchingRunes(a[:aStart], b[:bStart]) + + matchingRunes(a[aStart+length:], b[bStart+length:]) +} + +// longestCommonRun returns the start offsets in a and b of their longest +// common contiguous run, and its length. +// +// Ties resolve to the earliest run in a, then the earliest in b, matching +// difflib's behaviour. +func longestCommonRun(a, b []rune) (aStart, bStart, length int) { + // runLengths[j] is the length of the common run ending at a[i], b[j] for + // the row being scanned. It is rebuilt per row from its previous values, + // walking j backwards so each read happens before it is overwritten. + runLengths := make([]int, len(b)) + + for i := range a { + for j := len(b) - 1; j >= 0; j-- { + if a[i] != b[j] { + runLengths[j] = 0 + continue + } + if j == 0 { + runLengths[j] = 1 + } else { + runLengths[j] = runLengths[j-1] + 1 + } + if runLengths[j] > length { + length = runLengths[j] + aStart = i - length + 1 + bStart = j - length + 1 + } + } + } + return aStart, bStart, length +} From 5fbec3426df406aecc0649fb902a79bd7d2755d4 Mon Sep 17 00:00:00 2001 From: Josep Garcia-Reyero Sais Date: Fri, 31 Jul 2026 15:53:20 +0200 Subject: [PATCH 2/8] refactor(tools): give the semantic layer its own surface, off lfx-lens The semantic layer tools now call the dbt Semantic Layer in process through internal/dbtsl, instead of proxying through lfx-lens over HTTP. internal/tools/lens.go held both surfaces in 664 lines. It shrinks to query_lfx_lens alone, which still routes through lfx-lens and keeps its LFXMCP_LENS_API_* settings. Everything else moves to semanticlayer.go with its own config type. The tool descriptions and help texts move verbatim, since they carry prompt engineering that took three iterations to land and three tests guard them. The dbt client is constructed in a top-level block in main.go rather than nested inside the LFX API guard that wraps the lens client: it authenticates with a static service token and has no Auth0 dependency, so nesting it there would leave it unconfigured for an unrelated reason. It is deliberately not given the serviceapi debug transport, which dumps the Authorization header and would print the dbt service token into production logs, where debug traffic is currently on. project_slug is removed from the query tool rather than kept as an accepted no-op. Its description promised the where clause was 'validated against that foundation's subtree', and with the Snowflake scope check gone nothing enforces that. A parameter that silently does nothing while reading as a scoping guarantee is the same class of failure as a filter value that returns zero rows instead of an error. Scoping now happens in the where clause like any other filter, which is how it always actually worked. This freed 69 bytes of the query tool's 2048-byte description budget. Issue: LFXV2-2940 Signed-off-by: Josep Garcia-Reyero Sais --- cmd/lfx-mcp-server/main.go | 42 ++ internal/dbtsl/client.go | 12 +- internal/dbtsl/dbtsl_test.go | 10 + internal/tools/csv.go | 43 ++ internal/tools/lens.go | 518 ------------------ internal/tools/lens_test.go | 652 +--------------------- internal/tools/semanticlayer.go | 493 +++++++++++++++++ internal/tools/semanticlayer_test.go | 771 +++++++++++++++++++++++++++ 8 files changed, 1370 insertions(+), 1171 deletions(-) create mode 100644 internal/tools/csv.go create mode 100644 internal/tools/semanticlayer.go create mode 100644 internal/tools/semanticlayer_test.go diff --git a/cmd/lfx-mcp-server/main.go b/cmd/lfx-mcp-server/main.go index b5a025a..bb11a75 100644 --- a/cmd/lfx-mcp-server/main.go +++ b/cmd/lfx-mcp-server/main.go @@ -24,6 +24,7 @@ import ( "github.com/knadh/koanf/providers/env/v2" "github.com/knadh/koanf/v2" lfxauth "github.com/linuxfoundation/lfx-mcp/internal/auth" + "github.com/linuxfoundation/lfx-mcp/internal/dbtsl" "github.com/linuxfoundation/lfx-mcp/internal/lfxv2" localOtel "github.com/linuxfoundation/lfx-mcp/internal/otel" "github.com/linuxfoundation/lfx-mcp/internal/serviceapi" @@ -61,6 +62,13 @@ type Config struct { LensAPIURL string `koanf:"lens_api_url"` LensAPIAudience string `koanf:"lens_api_audience"` + // dbt Semantic Layer configuration. Independent of the LFX API settings + // above: this client authenticates with a static service token and has no + // Auth0 dependency. + DBTSLHost string `koanf:"dbt_sl_host"` + DBTSLEnvironmentID string `koanf:"dbt_sl_environment_id"` + DBTSLToken string `koanf:"dbt_sl_token"` + // Feature flags. CommitteesAsGroups bool `koanf:"committees_as_groups"` } @@ -225,6 +233,9 @@ func main() { f.String("onboarding_api_audience", "", "Auth0 resource server audience for the member onboarding API") f.String("lens_api_url", "", "Base URL of the LFX Lens service") f.String("lens_api_audience", "", "Auth0 resource server audience for the LFX Lens API") + f.String("dbt_sl_host", "", "dbt Semantic Layer host, e.g. tj283.semantic-layer.us1.dbt.com") + f.String("dbt_sl_environment_id", "", "dbt Semantic Layer environment ID") + f.String("dbt_sl_token", "", "dbt Semantic Layer service token") if err := f.Parse(os.Args[1:]); err != nil { fmt.Fprintf(os.Stderr, "Failed to parse flags: %v\n", err) @@ -475,6 +486,37 @@ func main() { } } + // The dbt Semantic Layer client is configured on its own, outside the LFX + // API block above. It authenticates with a static service token rather than + // an Auth0 client-credentials exchange, so nesting it under those settings + // would leave it unconfigured for an unrelated reason. + // + // The HTTP client is deliberately not wrapped in the serviceapi debug + // transport: that dumps the Authorization header, which would print this + // long-lived service token into the logs whenever debug traffic is on, and + // it currently is in production. + if cfg.DBTSLHost != "" && cfg.DBTSLEnvironmentID != "" && cfg.DBTSLToken != "" { + semanticLayerClient, err := dbtsl.NewClient(dbtsl.Config{ + Host: cfg.DBTSLHost, + EnvironmentID: cfg.DBTSLEnvironmentID, + Token: cfg.DBTSLToken, + HTTPClient: &http.Client{ + // Query execution polls, so a single round trip is short even + // when the warehouse is slow. + Timeout: 60 * time.Second, + Transport: otelhttp.NewTransport(http.DefaultTransport), + }, + }) + if err != nil { + logger.Warn("failed to create dbt Semantic Layer client", errKey, err) + } else { + tools.SetSemanticLayerConfig(&tools.SemanticLayerConfig{Client: semanticLayerClient}) + logger.Info("semantic layer tools configured", "host", cfg.DBTSLHost, "environment_id", cfg.DBTSLEnvironmentID) + } + } else { + logger.Warn("dbt Semantic Layer not configured - semantic layer tools will return an error if called") + } + // Validate configuration for HTTP mode. if cfg.Mode == "http" { if len(cfg.MCPAPI.AuthServers) == 0 { diff --git a/internal/dbtsl/client.go b/internal/dbtsl/client.go index caa8bc3..0430242 100644 --- a/internal/dbtsl/client.go +++ b/internal/dbtsl/client.go @@ -82,8 +82,16 @@ type Client struct { // NewClient validates cfg and returns a ready client. func NewClient(cfg Config) (*Client, error) { + // The host is normally bare, e.g. "tj283.semantic-layer.us1.dbt.com", and + // is reached over https. An explicit http:// prefix is honoured so the + // client can be pointed at a local stub. host := strings.TrimSpace(cfg.Host) - host = strings.TrimPrefix(strings.TrimPrefix(host, "https://"), "http://") + scheme := "https://" + if rest, found := strings.CutPrefix(host, "http://"); found { + scheme, host = "http://", rest + } else { + host = strings.TrimPrefix(host, "https://") + } host = strings.TrimSuffix(host, "/") if host == "" { return nil, fmt.Errorf("dbt Semantic Layer host is required") @@ -106,7 +114,7 @@ func NewClient(cfg Config) (*Client, error) { } return &Client{ - graphqlURL: "https://" + host + "/api/graphql", + graphqlURL: scheme + host + "/api/graphql", environmentID: parsedEnvID, token: cfg.Token, httpClient: httpClient, diff --git a/internal/dbtsl/dbtsl_test.go b/internal/dbtsl/dbtsl_test.go index d9c5813..305e736 100644 --- a/internal/dbtsl/dbtsl_test.go +++ b/internal/dbtsl/dbtsl_test.go @@ -850,3 +850,13 @@ func TestParseQueryResultKeepsLargeIntegersExact(t *testing.T) { t.Errorf("expected no scientific notation, got %s", encoded) } } + +func TestNewClientHonoursAnExplicitHTTPScheme(t *testing.T) { + client, err := NewClient(Config{Host: "http://127.0.0.1:9999", EnvironmentID: "1", Token: "t"}) + if err != nil { + t.Fatalf("NewClient failed: %v", err) + } + if client.graphqlURL != "http://127.0.0.1:9999/api/graphql" { + t.Errorf("expected the http scheme preserved, got %q", client.graphqlURL) + } +} diff --git a/internal/tools/csv.go b/internal/tools/csv.go new file mode 100644 index 0000000..9842a14 --- /dev/null +++ b/internal/tools/csv.go @@ -0,0 +1,43 @@ +// Copyright The Linux Foundation and contributors. +// SPDX-License-Identifier: MIT + +package tools + +import ( + "encoding/json" + "strings" +) + +// parseCSV splits a comma-separated string into trimmed, non-empty values. +// Also handles JSON-encoded arrays (e.g. `["a","b"]`) that some MCP clients send. +func parseCSV(s string) []string { + s = strings.TrimSpace(s) + if s == "" { + return nil + } + // Handle JSON array strings from clients that serialize arrays as strings. + // The ReplaceAll handles double-encoded strings with escaped quotes (e.g. `[\"a\",\"b\"]`). + if strings.HasPrefix(s, "[") { + cleaned := strings.ReplaceAll(s, `\"`, `"`) + var arr []string + if err := json.Unmarshal([]byte(cleaned), &arr); err == nil { + out := make([]string, 0, len(arr)) + for _, p := range arr { + p = strings.TrimSpace(p) + if p != "" { + out = append(out, p) + } + } + return out + } + } + parts := strings.Split(s, ",") + out := make([]string, 0, len(parts)) + for _, p := range parts { + p = strings.TrimSpace(p) + if p != "" { + out = append(out, p) + } + } + return out +} diff --git a/internal/tools/lens.go b/internal/tools/lens.go index 22e084f..f6b5fa6 100644 --- a/internal/tools/lens.go +++ b/internal/tools/lens.go @@ -9,8 +9,6 @@ import ( "encoding/json" "fmt" "net/http" - "net/url" - "strings" "time" "github.com/linuxfoundation/lfx-mcp/internal/serviceapi" @@ -146,519 +144,3 @@ func handleQueryLFXLens(ctx context.Context, req *mcp.CallToolRequest, args Quer Content: []mcp.Content{&mcp.TextContent{Text: resp.Content}}, }, nil, nil } - -// --------------------------------------------------------------------------- -// explore_lfx_semantic_layer / query_lfx_semantic_layer — structured metrics -// --------------------------------------------------------------------------- - -// Both descriptions are truncated at 2048 characters before the model ever sees -// them, so each must stay under that: anything past the cut is silently -// invisible, which is how earlier guidance (the tlf membership caveat, the -// project_name tip) went unread for as long as it did. -// TestSemanticLayerDescriptions_FitSchemaBudget guards the limit. -// -// Discovery and querying are split across two tools so that each gets its own -// budget, and so the query's MetricFlow syntax lives in a tool description -// rather than on an optional parameter — see the note on -// QuerySemanticLayerArgs for why that distinction matters. Anything that still -// does not fit belongs in the help action, whose output is a tool result and -// carries no limit; help is a fallback for a failed query, not a prerequisite. -const exploreSemanticLayerDescription = `The LFX Insights Semantic Layer is the query and data-exploration tool for Linux Foundation data. This half discovers what can be measured; query_lfx_semantic_layer runs it. Start here whenever you do not already know the exact metric, dimension and value names. - -COVERS — search one of these topic words: -- contributor, contribution — activity and org counts, commits, PRs -- membership, revenue, churn — counts, discounts, invoices -- event, registration, speaker — counts and revenue -- enrollment, certification — education -- maintainer — total and active counts -- health, project — health scores, software value, cost -- any of the above sliced by country or region — always here, never query_lfx_lens - -A metric is the number measured (total_contributors); a dimension is how you slice, filter or list it (country__lf_region). Names are entity__field and the prefix differs per metric, so copy qualified_names from this tool rather than assembling one — country__lf_region is a person's country, activity_project_id__organization_lf_region an organization's HQ. - -ACTIONS -- list_metrics(search): searches metric names and descriptions only, so search a topic word above, not a dimension word like "country". When 15 or fewer match, each returns its dimension qualified_names — usually enough to query. -- get_dimensions(metrics, search): dimensions available to those metrics; needs at least one. Passing several returns only the ones they share — what a cross-domain query can group by. -- get_dimension_values(dimension, metrics, search): the literals a dimension holds. Call it before filtering on any value not already seen in output: an unknown literal returns zero rows, not an error, so a wrong guess reads as missing data. Spellings surprise — 'Asia Pacific' not 'APAC', 'Viet Nam' not 'Vietnam'. -- help(target): worked query examples, for when a query fails. - -USE query_lfx_lens INSTEAD for questions that do not reduce to a metric above: narrative or "why", subprojects, maintainer trends, event sponsorships.` - -const querySemanticLayerDescription = `The LFX Insights Semantic Layer is the query and data-exploration tool for Linux Foundation data; this half runs the query. Covers contributions, memberships, events, education, maintainers and project health — and anything sliced by country or region. ALWAYS call explore_lfx_semantic_layer first unless you already have the exact metric, dimension and entity names — never guess or assemble one: a wrong name errors, and a wrong filter value returns no rows rather than an error, so confirm literals with get_dimension_values. Use query_lfx_lens for narrative or "why" questions that do not reduce to a metric. - - metrics (required): comma-separated names. List several to combine them in one result, even across domains — they are joined on the dimensions they have in common, the only set such a query can group by. The join is outer, so a group in only one domain still appears with NULL for the other. - group_by: dimension qualified_names, comma-separated, copied verbatim. Group by a name dimension for a ranked list of organizations, people or projects. For a trend add metric_time__year, or __quarter, __month, __week, __day. - where: MetricFlow filter; this does the filtering. - categorical {{ Dimension('country__lf_region') }} = 'Europe' - time {{ TimeDimension('asset_id__install_date', 'DAY') }} >= '2024-01-01' - Dates are yyyy-mm-dd. - order_by: comma-separated; each field must also appear in group_by or metrics. Prefix - for descending. - limit: ceiling 500. Use 10-20 for top-N, 50-100 for full breakdowns. - project_slug: optional. Omit it for global or cross-foundation questions — the normal case for country and region ones. When given, the where clause must also carry a project filter, validated against that foundation's subtree. - -Many metrics are pre-filtered — current_* is active-only, total_contributors excludes bots — so do not re-filter those. Entities listed with a metric are join keys, not group-by values: grouping by one returns raw IDs, so use the name dimension.` - -// The two semantic layer tools register independently so that LFXMCP_TOOLS can -// select either by name. They are meant to be enabled together — each -// description points at the other — but a shared gate would mean the name -// "explore_lfx_semantic_layer" registered nothing while -// "query_lfx_semantic_layer" silently registered both. -// -// The registration gate in cmd/lfx-mcp-server limits both to staff callers, so -// project scoping is optional here; lfx-lens validates any project filters that -// are provided against the requested foundation's subtree. -// -// Discovery and querying are separate tools rather than actions on one tool -// because a tool description and its required parameters are the only guidance -// that reaches the model intact — see the note on QuerySemanticLayerArgs. -// Splitting gives the query its own description to hold the MetricFlow syntax, -// and makes metrics genuinely required there rather than optional. - -// RegisterExploreSemanticLayer registers the explore_lfx_semantic_layer tool. -func RegisterExploreSemanticLayer(server *mcp.Server) { - mcp.AddTool(server, &mcp.Tool{ - Name: "explore_lfx_semantic_layer", - Description: exploreSemanticLayerDescription, - Annotations: &mcp.ToolAnnotations{ - Title: "Explore LFX Semantic Layer", - ReadOnlyHint: true, - }, - }, handleExploreSemanticLayer) -} - -// RegisterQuerySemanticLayer registers the query_lfx_semantic_layer tool. -func RegisterQuerySemanticLayer(server *mcp.Server) { - mcp.AddTool(server, &mcp.Tool{ - Name: "query_lfx_semantic_layer", - Description: querySemanticLayerDescription, - Annotations: &mcp.ToolAnnotations{ - Title: "Query LFX Semantic Layer", - ReadOnlyHint: true, - }, - }, handleQuerySemanticLayer) -} - -// ExploreSemanticLayerArgs defines the input for explore_lfx_semantic_layer. -// -// Action is the only required field, so under the schema compaction described -// on QuerySemanticLayerArgs it is the one parameter description that survives -// intact — hence the full action list lives there rather than being split -// across the optional fields. -type ExploreSemanticLayerArgs struct { - Action string `json:"action" jsonschema:"Required. One of: list_metrics, get_dimensions, get_dimension_values, help. Use get_dimension_values before filtering on any value you have not seen in output: a where clause with a real dimension but an unknown literal returns zero rows instead of an error, so a wrong guess looks exactly like missing data."` - Search string `json:"search,omitempty" jsonschema:"For list_metrics, a topic word ('contributor', 'membership', 'event', 'enrollment', 'maintainer', 'health'). For get_dimensions, the slice you are after, e.g. 'region', 'tier', 'name'. For get_dimension_values, a fragment of the value — keep it short, since the stored spelling often differs from the everyday one."` - Metrics string `json:"metrics,omitempty" jsonschema:"Comma-separated metric names. Required for get_dimensions and get_dimension_values; pass several to get_dimensions to see only the dimensions they share."` - Dimension string `json:"dimension,omitempty" jsonschema:"For action=get_dimension_values only: one dimension qualified_name, copied from get_dimensions (e.g. 'country__lf_region')."` - Target string `json:"target,omitempty" jsonschema:"For action=help only: which action to get examples for (e.g. 'query'). Omit for an overview."` -} - -// QuerySemanticLayerArgs defines the input for query_lfx_semantic_layer. -// -// Only the tool description and REQUIRED parameters reach the model intact. -// Clients that defer tool schemas behind a search index — Claude Desktop does — -// re-serialise the schema and replace optional parameter descriptions with a -// short generated summary. Verified against a live client: a 459-byte where -// description arrived as "Filter conditions." and order_by as "Sort order.". -// Temporarily marking limit required was enough to make its real description -// appear, which is what pinned the cause down. -// -// So Metrics is required here — it carries the multi-metric join rules — and -// anything else the model must not get wrong, above all the MetricFlow filter -// syntax it cannot guess, is repeated in querySemanticLayerDescription. The -// optional descriptions below stay full and accurate for clients that pass them -// through unchanged; they just are not the only copy. -// TestCriticalGuidanceSurvivesSchemaCompaction guards that split. -type QuerySemanticLayerArgs struct { - Metrics string `json:"metrics" jsonschema:"Required. Comma-separated metric names taken from explore_lfx_semantic_layer — never guessed. List several to combine them in one result, even across domains: they are outer-joined on the dimensions they share, so a group present in only one domain still appears with NULL for the other metric, and you can only group by dimensions they have in common. Many metrics are already filtered — current_* means active-only, total_contributors excludes bots — so do not repeat those conditions in where."` - GroupBy string `json:"group_by,omitempty" jsonschema:"Comma-separated dimension qualified_names, copied verbatim from explore_lfx_semantic_layer — they are entity__field and the prefix differs per metric. Group by a name dimension for a ranked list of organizations, people or projects; add metric_time__year (or __quarter, __month, __week, __day) for a trend."` - Where string `json:"where,omitempty" jsonschema:"MetricFlow filter; this clause does the actual data filtering. Categorical: {{ Dimension('country__lf_region') }} = 'Europe'. Time: {{ TimeDimension('asset_id__install_date', 'DAY') }} >= '2024-01-01'. Dates are yyyy-mm-dd."` - OrderBy string `json:"order_by,omitempty" jsonschema:"Comma-separated sort fields. Each must also appear in group_by or metrics. Prefix with - for descending, e.g. -current_membership_revenue."` - Limit int `json:"limit,omitempty" jsonschema:"Maximum rows to return, ceiling 500. Use 10-20 for top-N questions and 50-100 for full breakdowns."` - ProjectSlug string `json:"project_slug,omitempty" jsonschema:"Optional project slug from search_projects (e.g. 'cncf'). Omit it for global or cross-foundation questions. When provided, the where clause must also carry a project filter, validated against that foundation's subtree."` -} - -// lensHelpTexts back the help action. These are tool results, so they carry no -// character budget — but they are a fallback, not a prerequisite: everything -// needed to compose a first query lives in exploreSemanticLayerDescription, -// querySemanticLayerDescription and the per-parameter descriptions. -var lensHelpTexts = map[string]string{ - "list_metrics": `list_metrics — discover metrics. Always the first call. - - search (optional): matches metric NAMES and DESCRIPTIONS only. - -Search by topic, not by the slice you want: "contributor", "membership", -"event", "enrollment", "maintainer", "health". Words that name a dimension — -"country", "region", "tier" — match no metrics at all. - -When 15 or fewer metrics match, each comes back with its dimension -qualified_names, which is usually enough to go straight to query. - -Each metric also lists its entities. Those are the keys that link domains, not -things to group by: they are why two metrics can be combined (both -total_contributors and current_membership_revenue carry country). To find what -you can actually group a multi-metric query by, call get_dimensions with both -metrics. - -Nothing returned? Broaden the topic or drop to a single word. An unknown metric -name is rejected with ranked suggestions — use them rather than guessing again.`, - - "get_dimensions": `get_dimensions — list the dimensions available to a set of metrics. - - metrics (required): comma-separated metric names. Dimensions cannot be - searched without a metric, so choose a metric first. - search (optional): filters by name and description, e.g. "region". - -Use each returned qualified_name verbatim in group_by and where. - -Passing several metrics returns only the dimensions they SHARE, and that set is -much smaller than either metric's own. Those shared dimensions are what a -cross-domain query can group by.`, - - "get_dimension_values": `get_dimension_values — list the literals a dimension can hold. - - dimension (required): one qualified_name from get_dimensions. - metrics (required): the metric you intend to query. The dimension is - checked against it, so the two must go together. - search (optional): case-insensitive substring. Keep it short — a fragment - like "viet" finds a value however it is spelled. - -Call this before filtering on any value you have not already seen in output. -An unknown literal is not an error: the query succeeds and returns zero rows, -which is indistinguishable from the data genuinely being empty. - -Stored spellings are not the everyday ones: - lf_region 'Asia Pacific', never 'APAC' - country_name 'Viet Nam', 'Korea, Republic of', 'Türkiye' — ISO spellings - -Values come from the dimension's full domain, not just rows carrying the -metric, so a value listed here can still return no rows once other filters are -applied. - -Prefer the country__* dimensions over asset_id__billing_country, which is -unnormalized free text and holds both 'Viet Nam' and 'Vietnam' alongside -entries like 'na', 'US' and 'Untied States'. Filtering on it drops members -filed under a different spelling.`, - - "query": lensQueryHelp, -} - -// lensHelpOverview is returned by help with no target. -const lensHelpOverview = `LFX Insights Semantic Layer — how to use it - -Workflow: list_metrics(search) → get_dimensions (only if you need more) → -get_dimension_values (before filtering on an unseen value) → query. - - metric the number being measured - dimension an attribute you group, filter or list by - entity the key that links domains — country, project, event, organization - -Because domains share entities, one query can span them: contribution metrics -and membership metrics both reach the country dimensions, so they can be -compared side by side in a single result. You never write a join — list several -metrics and group by a dimension they share, and the join path is derived from -the shared entity. - -Dimension qualified_names are entity__field. The prefix is the primary key of -the metric's own table, so it differs from metric to metric. Always copy the -name from list_metrics or get_dimensions. - -help targets: query, list_metrics, get_dimensions, get_dimension_values` - -const lensQueryHelp = `query — run a metric query. - - metrics (required) comma-separated metric names. - group_by (optional) dimension qualified_names, comma-separated. - where (optional) MetricFlow filter: - categorical {{ Dimension('country__lf_region') }} = 'Europe' - time {{ TimeDimension('asset_id__install_date', 'DAY') }} >= '2024-01-01' - dates yyyy-mm-dd. - order_by (optional) must also appear in group_by or metrics; - for descending. - limit (optional) ceiling 500. 10-20 for top-N, 50-100 for breakdowns. - -Trends: add metric_time__year (or __quarter, __month, __week, __day) to -group_by rather than writing date ranges by hand. - -Ranked lists: group by a name dimension, order by the metric descending, and -set a limit. - -Combining metrics from different domains outer-joins them, so a group with data -in only one domain still appears, with NULL for the other metric. - -Pre-filtered metrics: current_* is already active-only and total_contributors -already excludes bots. Do not add those conditions again. - -project_slug is optional. Supply it and the where clause must carry a project -filter, validated against that foundation's subtree. Omit both for global or -cross-foundation questions. - -Examples - - Active maintainers in CNCF - project_slug cncf - metrics active_maintainers - where {{ Dimension('maintainer_key__project_slug') }} = 'cncf' - - Membership revenue by tier, CNCF - project_slug cncf - metrics current_membership_revenue - group_by asset_id__membership_tier - where {{ Dimension('asset_id__project_slug') }} = 'cncf' - order_by -current_membership_revenue - - Top 10 organizations by contribution in a region - metrics total_contributors - group_by activity_project_id__organization_name - where {{ Dimension('activity_project_id__organization_lf_region') }} = 'Asia Pacific' - order_by -total_contributors - limit 10 - - Filter values are exact strings. lf_region is one of: North America, Europe, - China, India, Japan, Asia Pacific, Middle East & Africa, Latin America, Other. - For any other dimension use explore_lfx_semantic_layer's get_dimension_values - rather than guessing — a wrong literal returns zero rows, not an error. - - Contribution against financial involvement, by region, globally - metrics total_contributors, total_contributing_organizations, current_membership_revenue - group_by country__lf_region - order_by -current_membership_revenue - - European membership revenue trend by year - metrics current_membership_revenue - group_by country__lf_region, metric_time__year - where {{ Dimension('country__lf_region') }} = 'Europe' - limit 100` - -func handleExploreSemanticLayer(ctx context.Context, _ *mcp.CallToolRequest, args ExploreSemanticLayerArgs) (*mcp.CallToolResult, any, error) { - if lensConfig == nil { - return nil, nil, fmt.Errorf("LFX Lens tools not configured") - } - - switch args.Action { - // "describe" is the pre-rename name for help. It only helps a caller that - // has this tool but reuses the old action word — a caller still on the - // pre-split schema is addressing query_lfx_semantic_layer, which no longer - // takes an action at all and cannot reach here. Restoring that path would - // mean making metrics optional again on the query tool, which is exactly - // the compaction protection the split exists to get, so the stale-schema - // case is left to resolve itself when the client refreshes its tool list. - case "help", "describe": - return handleLensHelp(args.Target) - case "list_metrics": - return handleLensListMetrics(ctx, args.Search) - case "get_dimensions": - return handleLensGetDimensions(ctx, args.Metrics, args.Search) - case "get_dimension_values": - return handleLensGetDimensionValues(ctx, args.Dimension, args.Metrics, args.Search) - case "query": - // Reachable only from a caller that already has this tool and reused - // the old action word; a caller still on the pre-split schema is - // addressing query_lfx_semantic_layer and never lands here. See the - // note on the describe alias above. - return &mcp.CallToolResult{ - Content: []mcp.Content{&mcp.TextContent{Text: "Querying moved to the query_lfx_semantic_layer tool. Call it directly with metrics, group_by, where, order_by and limit."}}, - IsError: true, - }, nil, nil - default: - return &mcp.CallToolResult{ - Content: []mcp.Content{&mcp.TextContent{Text: fmt.Sprintf("Unknown action %q. Valid actions: list_metrics, get_dimensions, get_dimension_values, help. To run a query, use the query_lfx_semantic_layer tool.", args.Action)}}, - IsError: true, - }, nil, nil - } -} - -func handleLensHelp(target string) (*mcp.CallToolResult, any, error) { - if target == "" { - return &mcp.CallToolResult{ - Content: []mcp.Content{&mcp.TextContent{Text: lensHelpOverview}}, - }, nil, nil - } - - text, ok := lensHelpTexts[target] - if !ok { - return &mcp.CallToolResult{ - Content: []mcp.Content{&mcp.TextContent{Text: fmt.Sprintf("Unknown help target %q. Valid targets: list_metrics, get_dimensions, get_dimension_values, query", target)}}, - IsError: true, - }, nil, nil - } - - return &mcp.CallToolResult{ - Content: []mcp.Content{&mcp.TextContent{Text: text}}, - }, nil, nil -} - -func handleLensListMetrics(ctx context.Context, search string) (*mcp.CallToolResult, any, error) { - params := url.Values{} - if search != "" { - params.Set("search", search) - } - return lensDoGet(ctx, "/lfx-lens/semantic-layer/metrics", params) -} - -func handleLensGetDimensions(ctx context.Context, metricsArg, search string) (*mcp.CallToolResult, any, error) { - metrics := parseCSV(metricsArg) - if len(metrics) == 0 { - return &mcp.CallToolResult{ - Content: []mcp.Content{&mcp.TextContent{Text: "Error: metrics parameter is required for get_dimensions"}}, - IsError: true, - }, nil, nil - } - - params := url.Values{} - params.Set("metrics", strings.Join(metrics, ",")) - if search != "" { - params.Set("search", search) - } - return lensDoGet(ctx, "/lfx-lens/semantic-layer/dimensions", params) -} - -// handleLensGetDimensionValues lists the literals a dimension can hold. -// -// A where clause with a real dimension but an unknown value succeeds and -// returns no rows, so a wrong guess is indistinguishable from an empty result -// and gets read as "no such data". Seen live against 'APAC' (the value is -// 'Asia Pacific') and 'Vietnam' (it is 'Viet Nam'). -func handleLensGetDimensionValues(ctx context.Context, dimension, metricsArg, search string) (*mcp.CallToolResult, any, error) { - if strings.TrimSpace(dimension) == "" { - return &mcp.CallToolResult{ - Content: []mcp.Content{&mcp.TextContent{Text: "Error: dimension is required for get_dimension_values. Pass a qualified_name from get_dimensions, e.g. country__lf_region."}}, - IsError: true, - }, nil, nil - } - - metrics := parseCSV(metricsArg) - if len(metrics) == 0 { - return &mcp.CallToolResult{ - Content: []mcp.Content{&mcp.TextContent{Text: "Error: metrics is required for get_dimension_values — it is what the dimension is checked against. Pass the metric you intend to query."}}, - IsError: true, - }, nil, nil - } - - params := url.Values{} - params.Set("dimension", strings.TrimSpace(dimension)) - params.Set("metrics", strings.Join(metrics, ",")) - if search != "" { - params.Set("search", search) - } - return lensDoGet(ctx, "/lfx-lens/semantic-layer/dimension-values", params) -} - -func handleQuerySemanticLayer(ctx context.Context, _ *mcp.CallToolRequest, args QuerySemanticLayerArgs) (*mcp.CallToolResult, any, error) { - if lensConfig == nil { - return nil, nil, fmt.Errorf("LFX Lens tools not configured") - } - - metrics := parseCSV(args.Metrics) - if len(metrics) == 0 { - return &mcp.CallToolResult{ - Content: []mcp.Content{&mcp.TextContent{Text: "Error: metrics is required. Use explore_lfx_semantic_layer with action=list_metrics to find metric names."}}, - IsError: true, - }, nil, nil - } - - if args.Limit > 500 { - return &mcp.CallToolResult{ - Content: []mcp.Content{&mcp.TextContent{Text: "Error: limit must be 500 or less"}}, - IsError: true, - }, nil, nil - } - - reqBody := map[string]any{ - "metrics": metrics, - } - if args.ProjectSlug != "" { - // Omit project_slug entirely when empty: the lens API treats absence - // (not empty string) as "run without project scope validation". - reqBody["project_slug"] = args.ProjectSlug - } - if groupBy := parseCSV(args.GroupBy); len(groupBy) > 0 { - reqBody["group_by"] = groupBy - } - if args.Where != "" { - reqBody["where"] = []string{args.Where} - } - if orderBy := parseCSV(args.OrderBy); len(orderBy) > 0 { - reqBody["order_by"] = orderBy - } - if args.Limit > 0 { - reqBody["limit"] = args.Limit - } - - body, statusCode, err := lensConfig.ServiceClient.PostJSON(ctx, "/lfx-lens/semantic-layer/query", reqBody) - if err != nil { - return nil, nil, fmt.Errorf("query API call failed: %w", err) - } - if statusCode != http.StatusOK { - return &mcp.CallToolResult{ - Content: []mcp.Content{&mcp.TextContent{Text: fmt.Sprintf("Error (HTTP %d): %s", statusCode, string(body))}}, - IsError: true, - }, nil, nil - } - - return lensPrettyJSON(body) -} - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -// parseCSV splits a comma-separated string into trimmed, non-empty values. -// Also handles JSON-encoded arrays (e.g. `["a","b"]`) that some MCP clients send. -func parseCSV(s string) []string { - s = strings.TrimSpace(s) - if s == "" { - return nil - } - // Handle JSON array strings from clients that serialize arrays as strings. - // The ReplaceAll handles double-encoded strings with escaped quotes (e.g. `[\"a\",\"b\"]`). - if strings.HasPrefix(s, "[") { - cleaned := strings.ReplaceAll(s, `\"`, `"`) - var arr []string - if err := json.Unmarshal([]byte(cleaned), &arr); err == nil { - out := make([]string, 0, len(arr)) - for _, p := range arr { - p = strings.TrimSpace(p) - if p != "" { - out = append(out, p) - } - } - return out - } - } - parts := strings.Split(s, ",") - out := make([]string, 0, len(parts)) - for _, p := range parts { - p = strings.TrimSpace(p) - if p != "" { - out = append(out, p) - } - } - return out -} - -func lensDoGet(ctx context.Context, path string, params url.Values) (*mcp.CallToolResult, any, error) { - body, statusCode, err := lensConfig.ServiceClient.Get(ctx, path, params) - if err != nil { - return nil, nil, fmt.Errorf("API call to %s failed: %w", path, err) - } - if statusCode != http.StatusOK { - return &mcp.CallToolResult{ - Content: []mcp.Content{&mcp.TextContent{Text: fmt.Sprintf("Error (HTTP %d): %s", statusCode, string(body))}}, - IsError: true, - }, nil, nil - } - - return lensPrettyJSON(body) -} - -func lensPrettyJSON(body []byte) (*mcp.CallToolResult, any, error) { - var raw json.RawMessage - if err := json.Unmarshal(body, &raw); err != nil { - return &mcp.CallToolResult{ - Content: []mcp.Content{&mcp.TextContent{Text: string(body)}}, - }, nil, nil - } - pretty, _ := json.MarshalIndent(raw, "", " ") - return &mcp.CallToolResult{ - Content: []mcp.Content{&mcp.TextContent{Text: string(pretty)}}, - }, nil, nil -} diff --git a/internal/tools/lens_test.go b/internal/tools/lens_test.go index 8a43d4c..6a098ee 100644 --- a/internal/tools/lens_test.go +++ b/internal/tools/lens_test.go @@ -79,132 +79,7 @@ func resultText(t *testing.T, res *mcp.CallToolResult) string { } // --------------------------------------------------------------------------- -// Handler behavior -// --------------------------------------------------------------------------- - -func TestSemanticLayer_GlobalQueryOmitsProjectSlugAndWhere(t *testing.T) { - captured := setupLensTest(t) - - res, _, err := handleQuerySemanticLayer(context.Background(), &mcp.CallToolRequest{}, QuerySemanticLayerArgs{ - Metrics: "active_maintainers", - }) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if res.IsError { - t.Fatalf("unexpected error result: %s", resultText(t, res)) - } - if captured.Method != http.MethodPost || captured.Path != "/lfx-lens/semantic-layer/query" { - t.Errorf("unexpected request: %s %s", captured.Method, captured.Path) - } - - var body map[string]any - if err := json.Unmarshal(captured.Body, &body); err != nil { - t.Fatalf("failed to parse captured body: %v", err) - } - if _, ok := body["project_slug"]; ok { - t.Errorf("expected project_slug key to be absent from request body, got: %v", body["project_slug"]) - } - if _, ok := body["where"]; ok { - t.Errorf("expected where key to be absent from request body, got: %v", body["where"]) - } -} - -func TestSemanticLayer_ScopedQuerySendsProjectSlugAndWhere(t *testing.T) { - captured := setupLensTest(t) - - res, _, err := handleQuerySemanticLayer(context.Background(), &mcp.CallToolRequest{}, QuerySemanticLayerArgs{ - ProjectSlug: "cncf", - Metrics: "active_maintainers", - Where: "{{ Dimension('maintainer_key__project_slug') }} = 'cncf'", - }) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if res.IsError { - t.Fatalf("unexpected error result: %s", resultText(t, res)) - } - - var body map[string]any - if err := json.Unmarshal(captured.Body, &body); err != nil { - t.Fatalf("failed to parse captured body: %v", err) - } - if body["project_slug"] != "cncf" { - t.Errorf("expected project_slug 'cncf' in request body, got: %v", body["project_slug"]) - } - if _, ok := body["where"]; !ok { - t.Error("expected where key in request body") - } -} - -func TestSemanticLayer_ListMetricsWithoutProjectSlug(t *testing.T) { - captured := setupLensTest(t) - - res, _, err := handleExploreSemanticLayer(context.Background(), &mcp.CallToolRequest{}, ExploreSemanticLayerArgs{ - Action: "list_metrics", - }) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if res.IsError { - t.Fatalf("unexpected error result: %s", resultText(t, res)) - } - if captured.Path != "/lfx-lens/semantic-layer/metrics" { - t.Errorf("unexpected request path: %s", captured.Path) - } -} - -func TestSemanticLayer_GetDimensionsWithoutProjectSlug(t *testing.T) { - captured := setupLensTest(t) - - res, _, err := handleExploreSemanticLayer(context.Background(), &mcp.CallToolRequest{}, ExploreSemanticLayerArgs{ - Action: "get_dimensions", - Metrics: "active_maintainers", - }) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if res.IsError { - t.Fatalf("unexpected error result: %s", resultText(t, res)) - } - if captured.Path != "/lfx-lens/semantic-layer/dimensions" { - t.Errorf("unexpected request path: %s", captured.Path) - } -} - -func TestSemanticLayer_LimitTooLarge(t *testing.T) { - setupLensTest(t) - - res, _, err := handleQuerySemanticLayer(context.Background(), &mcp.CallToolRequest{}, QuerySemanticLayerArgs{ - Metrics: "active_maintainers", - Limit: 501, - }) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if !res.IsError || resultText(t, res) != "Error: limit must be 500 or less" { - t.Errorf("expected limit error, got: %q (IsError=%v)", resultText(t, res), res.IsError) - } -} - -func TestSemanticLayer_DescribeQuery(t *testing.T) { - setupLensTest(t) - - res, _, err := handleExploreSemanticLayer(context.Background(), &mcp.CallToolRequest{}, ExploreSemanticLayerArgs{ - Action: "describe", - Target: "query", - }) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - text := resultText(t, res) - if !strings.Contains(text, "project_slug is optional") { - t.Errorf("describe query text missing optional-scope wording: %q", text) - } -} - -// --------------------------------------------------------------------------- -// Description content +// Shared description-budget helpers // --------------------------------------------------------------------------- // schemaDescriptionBudget is the hard limit that makes the rest of this @@ -220,201 +95,6 @@ func TestSemanticLayer_DescribeQuery(t *testing.T) { // budget is enforced against the larger number. const schemaDescriptionBudget = 2048 -// TestSemanticLayerDescriptions_FitSchemaBudget guards that limit for both -// semantic layer tools. Splitting discovery from querying gave each its own -// budget, which is the point of the split. -func TestSemanticLayerDescriptions_FitSchemaBudget(t *testing.T) { - for name, desc := range map[string]string{ - "explore_lfx_semantic_layer": exploreSemanticLayerDescription, - "query_lfx_semantic_layer": querySemanticLayerDescription, - } { - if got := len(desc); got > schemaDescriptionBudget { - t.Errorf("%s description is %d bytes; everything past %d is invisible to the model — move detail into help", - name, got, schemaDescriptionBudget) - } - } -} - -// TestExploreSemanticLayerDescription checks the discovery tool carries the -// routing contract: which domains are ours, and when to use query_lfx_lens. -func TestExploreSemanticLayerDescription(t *testing.T) { - for _, want := range []string{ - // The domains are named explicitly. Without them the routing is - // one-sided — query_lfx_lens lists concrete triggers while this tool - // describes itself abstractly, so every specific question looks like a - // better match for the other tool. - // Search terms must be words the Semantic Layer actually matches. - // The earlier headings were plurals — "contributions", "memberships", - // "education", "project health" all returned zero metrics, and a live - // client followed the instruction, got [], and fell back to - // query_lfx_lens. These singular forms are verified against the API. - "contributor, contribution —", - "membership, revenue, churn —", - "event, registration, speaker —", - "enrollment, certification —", - "maintainer —", - "health, project —", - // Regional questions route here for every topic, memberships included. - "any of the above sliced by country or region — always here, never query_lfx_lens", - // Dimension naming, and the regional person-vs-organization split. - "entity__field", - "country__lf_region", - "activity_project_id__organization_lf_region", - // Discovery must hand off to the query tool by name. - "query_lfx_semantic_layer", - // The value-discovery action, and the reason it exists. A filter naming - // a real dimension but an unknown literal returns zero rows instead of - // erroring, so a wrong guess is indistinguishable from missing data. A - // live client burned five query attempts on 'APAC' and 'Vietnam' before - // escaping via a country code. - "get_dimension_values(dimension, metrics, search)", - "returns zero rows, not an error", - "'Asia Pacific' not 'APAC'", - "'Viet Nam' not 'Vietnam'", - // Either tool can be loaded without the other, so each states what the - // semantic layer is. Here the regional rule sits in COVERS, asserted - // above, rather than in the opening line. - "query and data-exploration tool", - } { - if !strings.Contains(exploreSemanticLayerDescription, want) { - t.Errorf("explore description missing %q", want) - } - } - - // Event sponsorships stay with query_lfx_lens, which does them better, so - // this tool must not advertise them. Listing "sponsorship" as a topic here - // put two tools in charge of the same question and contradicted the - // carve-out query_lfx_lens still states. - if strings.Contains(exploreSemanticLayerDescription, "sponsorship,") { - t.Error("explore description claims sponsorships as a topic; query_lfx_lens owns them") - } - - // The description used to warn that a plural search matches nothing. That - // stopped being true once lens learned to fall back to a singular stem: - // "memberships" now returns 18 metrics, "contributions" 2. Telling the model - // otherwise wastes the budget on a false constraint. - for _, unwanted := range []string{ - "a plural like", - "matches nothing", - } { - if strings.Contains(exploreSemanticLayerDescription, unwanted) { - t.Errorf("explore description still warns about plurals, which lens now handles: %q", unwanted) - } - } -} - -// TestQuerySemanticLayerDescription checks the query tool is self-sufficient: -// its own description carries the syntax, so a caller never has to call help -// first. -func TestQuerySemanticLayerDescription(t *testing.T) { - for _, want := range []string{ - "metrics (required)", - "Dimension(", - "TimeDimension(", - "yyyy-mm-dd", - "ceiling 500", - "metric_time__year", - "The join is outer", - "ranked list", - "project_slug", - // Splitting discovery out made it possible to query without ever - // exploring, and a live client did exactly that — going straight to a - // query with guessed names. The rule has to be an instruction, not a - // conditional suggestion. - "ALWAYS call explore_lfx_semantic_layer first", - "never guess", - // Both neighbours are named so routing works from this tool too. - "explore_lfx_semantic_layer", - "query_lfx_lens", - "query and data-exploration tool", - "anything sliced by country or region", - // The silent-zero-rows warning is only actionable if it names the way - // out; without this the model retries the same wrong literal. - "get_dimension_values", - } { - if !strings.Contains(querySemanticLayerDescription, want) { - t.Errorf("query description missing %q", want) - } - } - for _, unwanted := range []string{ - "MUST include a project scope filter", - // Framings that understate the tool and misroute the questions it - // exists to answer: it compiles SQL per request rather than serving - // stored rollups, and grouping by a name dimension returns lists of - // named organizations and people, not only figures. - "pre-aggregated", - "returns numbers, not records", - } { - if strings.Contains(querySemanticLayerDescription, unwanted) { - t.Errorf("query description must not contain %q", unwanted) - } - } -} - -// TestSemanticLayerArgs_FieldsFitSchemaBudget holds the other half of the -// budget contract: each property description is a separate field, so each must -// independently stay under the limit. -func TestSemanticLayerArgs_FieldsFitSchemaBudget(t *testing.T) { - for _, tc := range []struct { - tool *mcp.Tool - props []string - }{ - {listExploreTool(t), []string{"action", "search", "metrics", "dimension", "target"}}, - {listQueryTool(t), []string{"metrics", "group_by", "where", "order_by", "limit", "project_slug"}}, - } { - for _, property := range tc.props { - if got := len(schemaPropertyDescription(t, tc.tool, property)); got > schemaDescriptionBudget { - t.Errorf("%s.%s description is %d bytes; everything past %d is invisible to the model", - tc.tool.Name, property, got, schemaDescriptionBudget) - } - } - } -} - -// TestCriticalGuidanceSurvivesSchemaCompaction is the load-bearing test for -// where guidance is allowed to live. -// -// Clients that defer tool schemas behind a search index re-serialise them and -// replace OPTIONAL parameter descriptions with a short generated summary. This -// was verified against a live client: the 459-byte where description arrived as -// "Filter conditions.", order_by as "Sort order.", and limit as no description -// at all — until limit was temporarily marked required, at which point its real -// text appeared. Only the tool description and required parameters survive. -// -// So syntax the model cannot guess must not live solely on an optional -// parameter. Keeping the full text there is fine and useful for clients that do -// pass it through; it just may not be the only copy. -func TestCriticalGuidanceSurvivesSchemaCompaction(t *testing.T) { - var surviving string - for _, tool := range []*mcp.Tool{listExploreTool(t), listQueryTool(t)} { - surviving += "\n" + tool.Description - for _, name := range schemaRequired(t, tool) { - surviving += "\n" + schemaPropertyDescription(t, tool, name) - } - } - - for _, tc := range []struct { - token string - why string - }{ - {"Dimension(", "categorical filter syntax is unguessable"}, - {"TimeDimension(", "time filter syntax is unguessable"}, - {"yyyy-mm-dd", "date format silently returns wrong rows if guessed"}, - {"ceiling 500", "over-limit requests are rejected outright"}, - {"metric_time__year", "the only way to build a trend"}, - {"entity__field", "dimension names cannot be assembled by hand"}, - {"outer-joined", "explains NULLs in cross-domain results"}, - {"raw IDs", "grouping by an entity silently returns unusable output"}, - {"get_dimension_values", "the only recovery from a wrong filter literal"}, - {"zero rows", "a wrong literal is silent, so the model must be told to check first"}, - } { - if !strings.Contains(surviving, tc.token) { - t.Errorf("%q reaches the model only via an optional parameter, where it gets summarised away (%s). Move it into the tool description or onto a required parameter.", - tc.token, tc.why) - } - } -} - // TestAllLensToolDescriptionsFitBudget guards every description that ships in // tools/list, not just the semantic layer's. query_lfx_lens has far less // headroom and is the likeliest to drift past the cut unnoticed. @@ -435,20 +115,6 @@ func TestAllLensToolDescriptionsFitBudget(t *testing.T) { } } -// --------------------------------------------------------------------------- -// Registration / schema -// --------------------------------------------------------------------------- - -func listExploreTool(t *testing.T) *mcp.Tool { - t.Helper() - return listRegisteredTool(t, "explore_lfx_semantic_layer", RegisterExploreSemanticLayer) -} - -func listQueryTool(t *testing.T) *mcp.Tool { - t.Helper() - return listRegisteredTool(t, "query_lfx_semantic_layer", RegisterQuerySemanticLayer) -} - // listRegisteredTool returns the named tool, failing the test if it is absent. func listRegisteredTool(t *testing.T, name string, register func(*mcp.Server)) *mcp.Tool { t.Helper() @@ -535,131 +201,6 @@ func schemaPropertyDescription(t *testing.T, tool *mcp.Tool, property string) st return prop.Description } -func TestRegisterSemanticLayer_Schema(t *testing.T) { - explore := listExploreTool(t) - query := listQueryTool(t) - - // Discovery: action is the only required field, and it must name exactly - // the actions the dispatcher accepts — a stale list sends the model to an - // action that errors. Querying lives on the other tool now. - exploreRequired := schemaRequired(t, explore) - if !contains(exploreRequired, "action") { - t.Errorf("explore required = %v; expected to contain action", exploreRequired) - } - if contains(exploreRequired, "metrics") { - t.Errorf("explore required = %v; metrics is only needed for get_dimensions", exploreRequired) - } - action := schemaPropertyDescription(t, explore, "action") - for _, want := range []string{"list_metrics", "get_dimensions", "get_dimension_values", "help"} { - if !strings.Contains(action, want) { - t.Errorf("action schema description missing %q: %q", want, action) - } - } - if strings.Contains(action, "describe") { - t.Errorf("action schema description still advertises the renamed describe action: %q", action) - } - - // Query: metrics is required, so its multi-metric join rules survive schema - // compaction. Everything else stays optional — above all project_slug, - // whose whole point is that global questions omit it. - queryRequired := schemaRequired(t, query) - if !contains(queryRequired, "metrics") { - t.Errorf("query required = %v; metrics must be required so its guidance survives compaction", queryRequired) - } - for _, optional := range []string{"project_slug", "where", "group_by", "order_by", "limit"} { - if contains(queryRequired, optional) { - t.Errorf("query required = %v; %s must stay optional", queryRequired, optional) - } - } - - // The optional descriptions are still expected to be complete, for clients - // that pass them through unchanged. - where := schemaPropertyDescription(t, query, "where") - for _, want := range []string{"Dimension(", "TimeDimension(", "yyyy-mm-dd"} { - if !strings.Contains(where, want) { - t.Errorf("where schema description missing %q: %q", want, where) - } - } - groupBy := schemaPropertyDescription(t, query, "group_by") - if !strings.Contains(groupBy, "metric_time__year") { - t.Errorf("group_by schema description missing the trend grain: %q", groupBy) - } - slug := schemaPropertyDescription(t, query, "project_slug") - if !strings.Contains(slug, "Omit it for global or cross-foundation questions") { - t.Errorf("project_slug schema description missing the optional-scope rule: %q", slug) - } -} - -// TestQueryToolRejectsMissingMetricsWithAPointer keeps the recovery path alive -// for a caller on a cached schema that still sends action=query here. -func TestQueryToolRejectsMissingMetricsWithAPointer(t *testing.T) { - setupLensTest(t) - - res, _, err := handleQuerySemanticLayer(context.Background(), &mcp.CallToolRequest{}, QuerySemanticLayerArgs{}) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if !res.IsError { - t.Fatal("expected an error result when metrics is empty") - } - if text := resultText(t, res); !strings.Contains(text, "explore_lfx_semantic_layer") { - t.Errorf("missing-metrics error should point at the discovery tool: %q", text) - } -} - -// TestExploreToolRedirectsQueryAction covers the other half of that migration: -// a caller still passing action=query to the discovery tool gets told where -// querying moved rather than a bare unknown-action error. -func TestExploreToolRedirectsQueryAction(t *testing.T) { - setupLensTest(t) - - res, _, err := handleExploreSemanticLayer(context.Background(), &mcp.CallToolRequest{}, ExploreSemanticLayerArgs{ - Action: "query", - }) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if !res.IsError { - t.Fatal("expected an error result for action=query on the discovery tool") - } - if text := resultText(t, res); !strings.Contains(text, "query_lfx_semantic_layer") { - t.Errorf("redirect should name the query tool: %q", text) - } -} - -// TestHelpActionAndDescribeAlias checks the renamed action works and that the -// old action word still dispatches on this tool. It deliberately does NOT claim -// to cover the pre-split schema: that caller addresses query_lfx_semantic_layer, -// which no longer accepts an action, so no assertion here can exercise it. -func TestHelpActionAndDescribeAlias(t *testing.T) { - setupLensTest(t) - - for _, action := range []string{"help", "describe"} { - res, _, err := handleExploreSemanticLayer(context.Background(), &mcp.CallToolRequest{}, ExploreSemanticLayerArgs{ - Action: action, - }) - if err != nil { - t.Fatalf("action %q: unexpected error: %v", action, err) - } - if text := resultText(t, res); !strings.Contains(text, "how to use it") { - t.Errorf("action %q did not return the help overview: %q", action, text) - } - } - - res, _, err := handleExploreSemanticLayer(context.Background(), &mcp.CallToolRequest{}, ExploreSemanticLayerArgs{ - Action: "help", - Target: "query", - }) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - // The worked examples are the reason help exists; they must survive the - // move off the description. - if text := resultText(t, res); !strings.Contains(text, "metric_time__year") { - t.Errorf("help query text missing the trend example: %q", text) - } -} - // TestQueryLFXLensDoesNotClaimMemberships guards the other half of the routing // contract. query_lfx_lens used to open with "Always use this tool for: // Membership questions", carved out only for country/region. Memberships now @@ -699,194 +240,3 @@ func contains(list []string, want string) bool { } return false } - -// --------------------------------------------------------------------------- -// get_dimension_values -// -// The action exists because a filter naming a real dimension but an unknown -// literal is not an error: the query succeeds and returns zero rows. Against a -// live client that read as "no such data" and cost five wrong-but-successful -// queries — 'APAC' for a region that is stored as 'Asia Pacific', 'Vietnam' for -// a country stored as 'Viet Nam'. -// --------------------------------------------------------------------------- - -func TestGetDimensionValuesForwardsToTheValuesEndpoint(t *testing.T) { - captured := setupLensTest(t) - - res, _, err := handleExploreSemanticLayer(context.Background(), &mcp.CallToolRequest{}, ExploreSemanticLayerArgs{ - Action: "get_dimension_values", - Dimension: " country__lf_region ", - Metrics: " total_contributors , current_membership_revenue ", - Search: "asia", - }) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if res.IsError { - t.Fatalf("unexpected error result: %s", resultText(t, res)) - } - if captured.Path != "/lfx-lens/semantic-layer/dimension-values" { - t.Errorf("unexpected request path: %s", captured.Path) - } - // Whitespace around a copied qualified_name must not reach lens, which - // rejects anything outside [A-Za-z0-9_] rather than trimming it. - if got := captured.Query.Get("dimension"); got != "country__lf_region" { - t.Errorf("dimension = %q; want it trimmed to country__lf_region", got) - } - if got := captured.Query.Get("metrics"); got != "total_contributors,current_membership_revenue" { - t.Errorf("metrics = %q; want the CSV normalised", got) - } - if got := captured.Query.Get("search"); got != "asia" { - t.Errorf("search = %q; want asia", got) - } -} - -func TestGetDimensionValuesOmitsAnEmptySearch(t *testing.T) { - captured := setupLensTest(t) - - _, _, err := handleExploreSemanticLayer(context.Background(), &mcp.CallToolRequest{}, ExploreSemanticLayerArgs{ - Action: "get_dimension_values", - Dimension: "country__lf_region", - Metrics: "total_contributors", - }) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - // An empty search must be absent, not sent as "": lens turns a present - // search into an ILIKE '%%' filter and would report zero matches. - if captured.Query.Has("search") { - t.Errorf("search should be omitted when empty, got %q", captured.Query.Get("search")) - } -} - -func TestGetDimensionValuesRejectsMissingArgumentsWithAPointer(t *testing.T) { - for _, tc := range []struct { - name string - args ExploreSemanticLayerArgs - want string - }{ - { - name: "no dimension", - args: ExploreSemanticLayerArgs{Action: "get_dimension_values", Metrics: "total_contributors"}, - want: "country__lf_region", - }, - { - name: "blank dimension", - args: ExploreSemanticLayerArgs{Action: "get_dimension_values", Dimension: " ", Metrics: "total_contributors"}, - want: "country__lf_region", - }, - { - name: "no metrics", - args: ExploreSemanticLayerArgs{Action: "get_dimension_values", Dimension: "country__lf_region"}, - want: "metrics is required", - }, - } { - t.Run(tc.name, func(t *testing.T) { - setupLensTest(t) - - res, _, err := handleExploreSemanticLayer(context.Background(), &mcp.CallToolRequest{}, tc.args) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if !res.IsError { - t.Fatal("expected an error result") - } - if text := resultText(t, res); !strings.Contains(text, tc.want) { - t.Errorf("error should show the way forward (%q): %q", tc.want, text) - } - }) - } -} - -// TestUnknownActionListsTheRealActions guards the recovery message against -// drift: it is what a model reads after guessing an action name, so an action -// missing here is one it will not retry with. -func TestUnknownActionListsTheRealActions(t *testing.T) { - setupLensTest(t) - - res, _, err := handleExploreSemanticLayer(context.Background(), &mcp.CallToolRequest{}, ExploreSemanticLayerArgs{ - Action: "list_dimension_values", - }) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if !res.IsError { - t.Fatal("expected an error result for an unknown action") - } - text := resultText(t, res) - for _, want := range []string{"list_metrics", "get_dimensions", "get_dimension_values", "help"} { - if !strings.Contains(text, want) { - t.Errorf("unknown-action error missing %q: %q", want, text) - } - } -} - -// TestHelpCoversGetDimensionValues checks the long-form guidance is reachable. -// It is the only place that records the billing_country trap, which has no room -// in the 2048-byte description. -func TestHelpCoversGetDimensionValues(t *testing.T) { - setupLensTest(t) - - res, _, err := handleExploreSemanticLayer(context.Background(), &mcp.CallToolRequest{}, ExploreSemanticLayerArgs{ - Action: "help", - Target: "get_dimension_values", - }) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if res.IsError { - t.Fatalf("unexpected error result: %s", resultText(t, res)) - } - text := resultText(t, res) - for _, want := range []string{ - "zero rows", - "'Asia Pacific'", - "Viet Nam", - // asset_id__billing_country is free text holding both spellings, so a - // filter on it drops members filed under the other one. The transcript - // that motivated this work "succeeded" on exactly that dimension. - "asset_id__billing_country", - } { - if !strings.Contains(text, want) { - t.Errorf("get_dimension_values help missing %q", want) - } - } - - // The overview must advertise the target, or nothing points at it. - overview, _, err := handleExploreSemanticLayer(context.Background(), &mcp.CallToolRequest{}, ExploreSemanticLayerArgs{ - Action: "help", - }) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if text := resultText(t, overview); !strings.Contains(text, "get_dimension_values") { - t.Errorf("help overview does not mention get_dimension_values: %q", text) - } -} - -// TestSemanticLayerToolsRegisterIndependently guards tool selection. -// -// Both tools used to be added by one function behind one gate keyed to -// "query_lfx_semantic_layer", so LFXMCP_TOOLS=explore_lfx_semantic_layer -// registered nothing at all, and selecting only the query tool silently -// exposed both. Each name must control exactly its own tool. -func TestSemanticLayerToolsRegisterIndependently(t *testing.T) { - for _, tc := range []struct { - name string - register func(*mcp.Server) - absent string - }{ - {"explore_lfx_semantic_layer", RegisterExploreSemanticLayer, "query_lfx_semantic_layer"}, - {"query_lfx_semantic_layer", RegisterQuerySemanticLayer, "explore_lfx_semantic_layer"}, - } { - t.Run(tc.name, func(t *testing.T) { - if tool := listRegisteredTool(t, tc.name, tc.register); tool == nil { - t.Fatalf("%s did not register itself", tc.name) - } - if found := findRegisteredTool(t, tc.absent, tc.register); found != nil { - t.Errorf("registering %s also exposed %s; each name must select only its own tool", - tc.name, tc.absent) - } - }) - } -} diff --git a/internal/tools/semanticlayer.go b/internal/tools/semanticlayer.go new file mode 100644 index 0000000..60fc6d3 --- /dev/null +++ b/internal/tools/semanticlayer.go @@ -0,0 +1,493 @@ +// Copyright The Linux Foundation and contributors. +// SPDX-License-Identifier: MIT + +package tools + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + + "github.com/linuxfoundation/lfx-mcp/internal/dbtsl" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// SemanticLayerConfig holds configuration for the dbt Semantic Layer tools. +// +// These tools talk to the dbt Semantic Layer directly. They are deliberately +// independent of the LFX Lens client, which now backs only query_lfx_lens. +type SemanticLayerConfig struct { + Client *dbtsl.Client +} + +var semanticLayerConfig *SemanticLayerConfig + +// SetSemanticLayerConfig sets the configuration for the semantic layer tools. +func SetSemanticLayerConfig(cfg *SemanticLayerConfig) { + semanticLayerConfig = cfg +} + +// Both descriptions are truncated at 2048 characters before the model ever sees +// them, so each must stay under that: anything past the cut is silently +// invisible, which is how earlier guidance (the tlf membership caveat, the +// project_name tip) went unread for as long as it did. +// TestSemanticLayerDescriptions_FitSchemaBudget guards the limit. +// +// Discovery and querying are split across two tools so that each gets its own +// budget, and so the query's MetricFlow syntax lives in a tool description +// rather than on an optional parameter — see the note on +// QuerySemanticLayerArgs for why that distinction matters. Anything that still +// does not fit belongs in the help action, whose output is a tool result and +// carries no limit; help is a fallback for a failed query, not a prerequisite. +const exploreSemanticLayerDescription = `The LFX Insights Semantic Layer is the query and data-exploration tool for Linux Foundation data. This half discovers what can be measured; query_lfx_semantic_layer runs it. Start here whenever you do not already know the exact metric, dimension and value names. + +COVERS — search one of these topic words: +- contributor, contribution — activity and org counts, commits, PRs +- membership, revenue, churn — counts, discounts, invoices +- event, registration, speaker — counts and revenue +- enrollment, certification — education +- maintainer — total and active counts +- health, project — health scores, software value, cost +- any of the above sliced by country or region — always here, never query_lfx_lens + +A metric is the number measured (total_contributors); a dimension is how you slice, filter or list it (country__lf_region). Names are entity__field and the prefix differs per metric, so copy qualified_names from this tool rather than assembling one — country__lf_region is a person's country, activity_project_id__organization_lf_region an organization's HQ. + +ACTIONS +- list_metrics(search): searches metric names and descriptions only, so search a topic word above, not a dimension word like "country". When 15 or fewer match, each returns its dimension qualified_names — usually enough to query. +- get_dimensions(metrics, search): dimensions available to those metrics; needs at least one. Passing several returns only the ones they share — what a cross-domain query can group by. +- get_dimension_values(dimension, metrics, search): the literals a dimension holds. Call it before filtering on any value not already seen in output: an unknown literal returns zero rows, not an error, so a wrong guess reads as missing data. Spellings surprise — 'Asia Pacific' not 'APAC', 'Viet Nam' not 'Vietnam'. +- help(target): worked query examples, for when a query fails. + +USE query_lfx_lens INSTEAD for questions that do not reduce to a metric above: narrative or "why", subprojects, maintainer trends, event sponsorships.` + +const querySemanticLayerDescription = `The LFX Insights Semantic Layer is the query and data-exploration tool for Linux Foundation data; this half runs the query. Covers contributions, memberships, events, education, maintainers and project health — and anything sliced by country or region. ALWAYS call explore_lfx_semantic_layer first unless you already have the exact metric, dimension and entity names — never guess or assemble one: a wrong name errors, and a wrong filter value returns no rows rather than an error, so confirm literals with get_dimension_values. Use query_lfx_lens for narrative or "why" questions that do not reduce to a metric. + + metrics (required): comma-separated names. List several to combine them in one result, even across domains — they are joined on the dimensions they have in common, the only set such a query can group by. The join is outer, so a group in only one domain still appears with NULL for the other. + group_by: dimension qualified_names, comma-separated, copied verbatim. Group by a name dimension for a ranked list of organizations, people or projects. For a trend add metric_time__year, or __quarter, __month, __week, __day. + where: MetricFlow filter; this does the filtering, including by project or foundation. + categorical {{ Dimension('country__lf_region') }} = 'Europe' + time {{ TimeDimension('asset_id__install_date', 'DAY') }} >= '2024-01-01' + Dates are yyyy-mm-dd. + order_by: comma-separated; each field must also appear in group_by or metrics. Prefix - for descending. + limit: ceiling 500. Use 10-20 for top-N, 50-100 for full breakdowns. + +Queries are global by default. To restrict one to a project or foundation, put that filter in where — there is no separate scope parameter. + +Many metrics are pre-filtered — current_* is active-only, total_contributors excludes bots — so do not re-filter those. Entities listed with a metric are join keys, not group-by values: grouping by one returns raw IDs, so use the name dimension.` + +// The two semantic layer tools register independently so that LFXMCP_TOOLS can +// select either by name. They are meant to be enabled together — each +// description points at the other — but a shared gate would mean the name +// "explore_lfx_semantic_layer" registered nothing while +// "query_lfx_semantic_layer" silently registered both. +// +// The registration gate in cmd/lfx-mcp-server limits both to staff callers. +// There is no per-query project scoping: a caller restricts a query by writing +// the filter into the where clause like any other. +// +// Discovery and querying are separate tools rather than actions on one tool +// because a tool description and its required parameters are the only guidance +// that reaches the model intact — see the note on QuerySemanticLayerArgs. +// Splitting gives the query its own description to hold the MetricFlow syntax, +// and makes metrics genuinely required there rather than optional. + +// RegisterExploreSemanticLayer registers the explore_lfx_semantic_layer tool. +func RegisterExploreSemanticLayer(server *mcp.Server) { + mcp.AddTool(server, &mcp.Tool{ + Name: "explore_lfx_semantic_layer", + Description: exploreSemanticLayerDescription, + Annotations: &mcp.ToolAnnotations{ + Title: "Explore LFX Semantic Layer", + ReadOnlyHint: true, + }, + }, handleExploreSemanticLayer) +} + +// RegisterQuerySemanticLayer registers the query_lfx_semantic_layer tool. +func RegisterQuerySemanticLayer(server *mcp.Server) { + mcp.AddTool(server, &mcp.Tool{ + Name: "query_lfx_semantic_layer", + Description: querySemanticLayerDescription, + Annotations: &mcp.ToolAnnotations{ + Title: "Query LFX Semantic Layer", + ReadOnlyHint: true, + }, + }, handleQuerySemanticLayer) +} + +// ExploreSemanticLayerArgs defines the input for explore_lfx_semantic_layer. +// +// Action is the only required field, so under the schema compaction described +// on QuerySemanticLayerArgs it is the one parameter description that survives +// intact — hence the full action list lives there rather than being split +// across the optional fields. +type ExploreSemanticLayerArgs struct { + Action string `json:"action" jsonschema:"Required. One of: list_metrics, get_dimensions, get_dimension_values, help. Use get_dimension_values before filtering on any value you have not seen in output: a where clause with a real dimension but an unknown literal returns zero rows instead of an error, so a wrong guess looks exactly like missing data."` + Search string `json:"search,omitempty" jsonschema:"For list_metrics, a topic word ('contributor', 'membership', 'event', 'enrollment', 'maintainer', 'health'). For get_dimensions, the slice you are after, e.g. 'region', 'tier', 'name'. For get_dimension_values, a fragment of the value — keep it short, since the stored spelling often differs from the everyday one."` + Metrics string `json:"metrics,omitempty" jsonschema:"Comma-separated metric names. Required for get_dimensions and get_dimension_values; pass several to get_dimensions to see only the dimensions they share."` + Dimension string `json:"dimension,omitempty" jsonschema:"For action=get_dimension_values only: one dimension qualified_name, copied from get_dimensions (e.g. 'country__lf_region')."` + Target string `json:"target,omitempty" jsonschema:"For action=help only: which action to get examples for (e.g. 'query'). Omit for an overview."` +} + +// QuerySemanticLayerArgs defines the input for query_lfx_semantic_layer. +// +// Only the tool description and REQUIRED parameters reach the model intact. +// Clients that defer tool schemas behind a search index — Claude Desktop does — +// re-serialise the schema and replace optional parameter descriptions with a +// short generated summary. Verified against a live client: a 459-byte where +// description arrived as "Filter conditions." and order_by as "Sort order.". +// Temporarily marking limit required was enough to make its real description +// appear, which is what pinned the cause down. +// +// So Metrics is required here — it carries the multi-metric join rules — and +// anything else the model must not get wrong, above all the MetricFlow filter +// syntax it cannot guess, is repeated in querySemanticLayerDescription. The +// optional descriptions below stay full and accurate for clients that pass them +// through unchanged; they just are not the only copy. +// TestCriticalGuidanceSurvivesSchemaCompaction guards that split. +type QuerySemanticLayerArgs struct { + Metrics string `json:"metrics" jsonschema:"Required. Comma-separated metric names taken from explore_lfx_semantic_layer — never guessed. List several to combine them in one result, even across domains: they are outer-joined on the dimensions they share, so a group present in only one domain still appears with NULL for the other metric, and you can only group by dimensions they have in common. Many metrics are already filtered — current_* means active-only, total_contributors excludes bots — so do not repeat those conditions in where."` + GroupBy string `json:"group_by,omitempty" jsonschema:"Comma-separated dimension qualified_names, copied verbatim from explore_lfx_semantic_layer — they are entity__field and the prefix differs per metric. Group by a name dimension for a ranked list of organizations, people or projects; add metric_time__year (or __quarter, __month, __week, __day) for a trend."` + Where string `json:"where,omitempty" jsonschema:"MetricFlow filter; this clause does the actual data filtering, including restricting a query to a project or foundation. Categorical: {{ Dimension('country__lf_region') }} = 'Europe'. Time: {{ TimeDimension('asset_id__install_date', 'DAY') }} >= '2024-01-01'. Dates are yyyy-mm-dd."` + OrderBy string `json:"order_by,omitempty" jsonschema:"Comma-separated sort fields. Each must also appear in group_by or metrics. Prefix with - for descending, e.g. -current_membership_revenue."` + Limit int `json:"limit,omitempty" jsonschema:"Maximum rows to return, ceiling 500. Use 10-20 for top-N questions and 50-100 for full breakdowns."` +} + +// semanticLayerHelpTexts back the help action. These are tool results, so they carry no +// character budget — but they are a fallback, not a prerequisite: everything +// needed to compose a first query lives in exploreSemanticLayerDescription, +// querySemanticLayerDescription and the per-parameter descriptions. +var semanticLayerHelpTexts = map[string]string{ + "list_metrics": `list_metrics — discover metrics. Always the first call. + + search (optional): matches metric NAMES and DESCRIPTIONS only. + +Search by topic, not by the slice you want: "contributor", "membership", +"event", "enrollment", "maintainer", "health". Words that name a dimension — +"country", "region", "tier" — match no metrics at all. + +When 15 or fewer metrics match, each comes back with its dimension +qualified_names, which is usually enough to go straight to query. + +Each metric also lists its entities. Those are the keys that link domains, not +things to group by: they are why two metrics can be combined (both +total_contributors and current_membership_revenue carry country). To find what +you can actually group a multi-metric query by, call get_dimensions with both +metrics. + +Nothing returned? Broaden the topic or drop to a single word. An unknown metric +name is rejected with ranked suggestions — use them rather than guessing again.`, + + "get_dimensions": `get_dimensions — list the dimensions available to a set of metrics. + + metrics (required): comma-separated metric names. Dimensions cannot be + searched without a metric, so choose a metric first. + search (optional): filters by name and description, e.g. "region". + +Use each returned qualified_name verbatim in group_by and where. + +Passing several metrics returns only the dimensions they SHARE, and that set is +much smaller than either metric's own. Those shared dimensions are what a +cross-domain query can group by.`, + + "get_dimension_values": `get_dimension_values — list the literals a dimension can hold. + + dimension (required): one qualified_name from get_dimensions. + metrics (required): the metric you intend to query. The dimension is + checked against it, so the two must go together. + search (optional): case-insensitive substring. Keep it short — a fragment + like "viet" finds a value however it is spelled. + +Call this before filtering on any value you have not already seen in output. +An unknown literal is not an error: the query succeeds and returns zero rows, +which is indistinguishable from the data genuinely being empty. + +Stored spellings are not the everyday ones: + lf_region 'Asia Pacific', never 'APAC' + country_name 'Viet Nam', 'Korea, Republic of', 'Türkiye' — ISO spellings + +Values come from the dimension's full domain, not just rows carrying the +metric, so a value listed here can still return no rows once other filters are +applied. + +Prefer the country__* dimensions over asset_id__billing_country, which is +unnormalized free text and holds both 'Viet Nam' and 'Vietnam' alongside +entries like 'na', 'US' and 'Untied States'. Filtering on it drops members +filed under a different spelling.`, + + "query": semanticLayerQueryHelp, +} + +// semanticLayerHelpOverview is returned by help with no target. +const semanticLayerHelpOverview = `LFX Insights Semantic Layer — how to use it + +Workflow: list_metrics(search) → get_dimensions (only if you need more) → +get_dimension_values (before filtering on an unseen value) → query. + + metric the number being measured + dimension an attribute you group, filter or list by + entity the key that links domains — country, project, event, organization + +Because domains share entities, one query can span them: contribution metrics +and membership metrics both reach the country dimensions, so they can be +compared side by side in a single result. You never write a join — list several +metrics and group by a dimension they share, and the join path is derived from +the shared entity. + +Dimension qualified_names are entity__field. The prefix is the primary key of +the metric's own table, so it differs from metric to metric. Always copy the +name from list_metrics or get_dimensions. + +help targets: query, list_metrics, get_dimensions, get_dimension_values` + +const semanticLayerQueryHelp = `query — run a metric query. + + metrics (required) comma-separated metric names. + group_by (optional) dimension qualified_names, comma-separated. + where (optional) MetricFlow filter: + categorical {{ Dimension('country__lf_region') }} = 'Europe' + time {{ TimeDimension('asset_id__install_date', 'DAY') }} >= '2024-01-01' + dates yyyy-mm-dd. + order_by (optional) must also appear in group_by or metrics; - for descending. + limit (optional) ceiling 500. 10-20 for top-N, 50-100 for breakdowns. + +Trends: add metric_time__year (or __quarter, __month, __week, __day) to +group_by rather than writing date ranges by hand. + +Ranked lists: group by a name dimension, order by the metric descending, and +set a limit. + +Combining metrics from different domains outer-joins them, so a group with data +in only one domain still appears, with NULL for the other metric. + +Pre-filtered metrics: current_* is already active-only and total_contributors +already excludes bots. Do not add those conditions again. + +Queries are global by default. To restrict one to a project or foundation, put +that filter in the where clause like any other; there is no scope parameter. + +Examples + + Active maintainers in CNCF + metrics active_maintainers + where {{ Dimension('maintainer_key__project_slug') }} = 'cncf' + + Membership revenue by tier, CNCF + metrics current_membership_revenue + group_by asset_id__membership_tier + where {{ Dimension('asset_id__project_slug') }} = 'cncf' + order_by -current_membership_revenue + + Top 10 organizations by contribution in a region + metrics total_contributors + group_by activity_project_id__organization_name + where {{ Dimension('activity_project_id__organization_lf_region') }} = 'Asia Pacific' + order_by -total_contributors + limit 10 + + Filter values are exact strings. lf_region is one of: North America, Europe, + China, India, Japan, Asia Pacific, Middle East & Africa, Latin America, Other. + For any other dimension use explore_lfx_semantic_layer's get_dimension_values + rather than guessing — a wrong literal returns zero rows, not an error. + + Contribution against financial involvement, by region, globally + metrics total_contributors, total_contributing_organizations, current_membership_revenue + group_by country__lf_region + order_by -current_membership_revenue + + European membership revenue trend by year + metrics current_membership_revenue + group_by country__lf_region, metric_time__year + where {{ Dimension('country__lf_region') }} = 'Europe' + limit 100` + +// maxQueryLimit is the largest number of rows a single query may return. +const maxQueryLimit = 500 + +func handleExploreSemanticLayer(ctx context.Context, _ *mcp.CallToolRequest, args ExploreSemanticLayerArgs) (*mcp.CallToolResult, any, error) { + if semanticLayerConfig == nil || semanticLayerConfig.Client == nil { + return nil, nil, fmt.Errorf("semantic layer tools not configured") + } + + switch args.Action { + // "describe" is the pre-rename name for help. It only helps a caller that + // has this tool but reuses the old action word — a caller still on the + // pre-split schema is addressing query_lfx_semantic_layer, which no longer + // takes an action at all and cannot reach here. Restoring that path would + // mean making metrics optional again on the query tool, which is exactly + // the compaction protection the split exists to get, so the stale-schema + // case is left to resolve itself when the client refreshes its tool list. + case "help", "describe": + return handleSemanticLayerHelp(args.Target) + case "list_metrics": + return handleSLListMetrics(ctx, args.Search) + case "get_dimensions": + return handleSLGetDimensions(ctx, args.Metrics, args.Search) + case "get_dimension_values": + return handleSLGetDimensionValues(ctx, args.Dimension, args.Metrics, args.Search) + case "query": + // Reachable only from a caller that already has this tool and reused + // the old action word; a caller still on the pre-split schema is + // addressing query_lfx_semantic_layer and never lands here. See the + // note on the describe alias above. + return toolError("Querying moved to the query_lfx_semantic_layer tool. Call it directly with metrics, group_by, where, order_by and limit.") + default: + return toolError(fmt.Sprintf("Unknown action %q. Valid actions: list_metrics, get_dimensions, get_dimension_values, help. To run a query, use the query_lfx_semantic_layer tool.", args.Action)) + } +} + +func handleSemanticLayerHelp(target string) (*mcp.CallToolResult, any, error) { + if target == "" { + return toolText(semanticLayerHelpOverview) + } + + text, ok := semanticLayerHelpTexts[target] + if !ok { + return toolError(fmt.Sprintf("Unknown help target %q. Valid targets: list_metrics, get_dimensions, get_dimension_values, query", target)) + } + return toolText(text) +} + +func handleSLListMetrics(ctx context.Context, search string) (*mcp.CallToolResult, any, error) { + metrics, err := semanticLayerConfig.Client.FetchAllowedMetrics(ctx, search) + if err != nil { + return toolError(fmt.Sprintf("Metrics lookup failed: %v", err)) + } + + // An empty result for a search is a dead end, so name the topic words that + // do match rather than returning [] and leaving the caller to guess again. + if len(metrics) == 0 && strings.TrimSpace(search) != "" { + return toolError(dbtsl.NoMetricsDetail(search)) + } + return toolJSON(metrics) +} + +func handleSLGetDimensions(ctx context.Context, metricsArg, search string) (*mcp.CallToolResult, any, error) { + metrics := parseCSV(metricsArg) + if len(metrics) == 0 { + return toolError("Error: metrics parameter is required for get_dimensions") + } + if disallowed := dbtsl.ValidateMetrics(metrics); len(disallowed) > 0 { + return toolError(dbtsl.UnknownMetricsDetail(disallowed)) + } + + dimensions, err := semanticLayerConfig.Client.FetchDimensions(ctx, metrics) + if err != nil { + return toolError(fmt.Sprintf("Dimensions lookup failed: %v", err)) + } + + // The upstream API has no dimension search, so narrowing happens here, + // across the name and the description. + if term := strings.ToLower(strings.TrimSpace(search)); term != "" { + filtered := make([]dbtsl.DimensionInfo, 0, len(dimensions)) + for _, d := range dimensions { + if strings.Contains(strings.ToLower(d.Name+" "+d.Description), term) { + filtered = append(filtered, d) + } + } + dimensions = filtered + } + return toolJSON(dimensions) +} + +// handleSLGetDimensionValues lists the literals a dimension can hold. +// +// A where clause with a real dimension but an unknown value succeeds and +// returns no rows, so a wrong guess is indistinguishable from an empty result +// and gets read as "no such data". Seen live against 'APAC' (the value is +// 'Asia Pacific') and 'Vietnam' (it is 'Viet Nam'). +func handleSLGetDimensionValues(ctx context.Context, dimension, metricsArg, search string) (*mcp.CallToolResult, any, error) { + if strings.TrimSpace(dimension) == "" { + return toolError("Error: dimension is required for get_dimension_values. Pass a qualified_name from get_dimensions, e.g. country__lf_region.") + } + + metrics := parseCSV(metricsArg) + if len(metrics) == 0 { + return toolError("Error: metrics is required for get_dimension_values — it is what the dimension is checked against. Pass the metric you intend to query.") + } + + values, err := semanticLayerConfig.Client.FetchDimensionValues(ctx, dimension, metrics, search, 100) + if err != nil { + var unknown *dbtsl.UnknownDimensionError + if errors.As(err, &unknown) { + return toolError(unknown.Message) + } + var failed *dbtsl.QueryFailedError + if errors.As(err, &failed) { + return toolError(fmt.Sprintf("Query failed: %s", failed.Message)) + } + return toolError(fmt.Sprintf("Dimension values query failed: %v", err)) + } + + // An empty list reads as "this data does not exist". It usually means the + // stored spelling is not the everyday one, so say that instead. + if values.ValueCount == 0 { + return toolError(dbtsl.NoDimensionValuesDetail(dimension, strings.TrimSpace(search))) + } + return toolJSON(values) +} + +func handleQuerySemanticLayer(ctx context.Context, _ *mcp.CallToolRequest, args QuerySemanticLayerArgs) (*mcp.CallToolResult, any, error) { + if semanticLayerConfig == nil || semanticLayerConfig.Client == nil { + return nil, nil, fmt.Errorf("semantic layer tools not configured") + } + + metrics := parseCSV(args.Metrics) + if len(metrics) == 0 { + return toolError("Error: metrics is required. Use explore_lfx_semantic_layer with action=list_metrics to find metric names.") + } + if args.Limit > maxQueryLimit { + return toolError(fmt.Sprintf("Error: limit must be %d or less", maxQueryLimit)) + } + if disallowed := dbtsl.ValidateMetrics(metrics); len(disallowed) > 0 { + return toolError(dbtsl.UnknownMetricsDetail(disallowed)) + } + + queryArgs := dbtsl.QueryArgs{ + Metrics: metrics, + GroupBy: parseCSV(args.GroupBy), + OrderBy: parseCSV(args.OrderBy), + Limit: args.Limit, + } + if args.Where != "" { + queryArgs.Where = []string{args.Where} + } + + result, err := semanticLayerConfig.Client.Query(ctx, queryArgs) + if err != nil { + var failed *dbtsl.QueryFailedError + if errors.As(err, &failed) { + return toolError(fmt.Sprintf("Query failed: %s", failed.Message)) + } + return toolError(fmt.Sprintf("Query failed: %v", err)) + } + return toolJSON(result) +} + +// --------------------------------------------------------------------------- +// Result helpers +// --------------------------------------------------------------------------- + +func toolText(text string) (*mcp.CallToolResult, any, error) { + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: text}}, + }, nil, nil +} + +func toolError(text string) (*mcp.CallToolResult, any, error) { + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: text}}, + IsError: true, + }, nil, nil +} + +// toolJSON renders a value as indented JSON for the model to read. +func toolJSON(value any) (*mcp.CallToolResult, any, error) { + pretty, err := json.MarshalIndent(value, "", " ") + if err != nil { + return nil, nil, fmt.Errorf("failed to encode semantic layer result: %w", err) + } + return toolText(string(pretty)) +} diff --git a/internal/tools/semanticlayer_test.go b/internal/tools/semanticlayer_test.go new file mode 100644 index 0000000..92862a3 --- /dev/null +++ b/internal/tools/semanticlayer_test.go @@ -0,0 +1,771 @@ +// Copyright The Linux Foundation and contributors. +// SPDX-License-Identifier: MIT + +package tools + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/linuxfoundation/lfx-mcp/internal/dbtsl" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// capturedGraphQL records the GraphQL requests the stub semantic layer saw. +type capturedGraphQL struct { + Operations []string + Variables []map[string]any +} + +// operation returns the variables sent for the named GraphQL operation, and +// whether it was called at all. +func (c *capturedGraphQL) operation(name string) (map[string]any, bool) { + for i, op := range c.Operations { + if op == name { + return c.Variables[i], true + } + } + return nil, false +} + +// setupSemanticLayerTest points the shared semanticLayerConfig at a stub dbt +// Semantic Layer that answers every operation with a small fixed payload. The +// previous config is restored on cleanup. Tests using this must not run in +// parallel, because semanticLayerConfig is a package-level global. +func setupSemanticLayerTest(t *testing.T) *capturedGraphQL { + t.Helper() + + captured := &capturedGraphQL{} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + + var req struct { + Query string `json:"query"` + Variables map[string]any `json:"variables"` + } + _ = json.Unmarshal(body, &req) + + op := graphQLOperation(req.Query) + captured.Operations = append(captured.Operations, op) + captured.Variables = append(captured.Variables, req.Variables) + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(stubResponses[op])) + })) + t.Cleanup(srv.Close) + + client, err := dbtsl.NewClient(dbtsl.Config{ + Host: srv.URL, + EnvironmentID: "1", + Token: "test-token", + }) + if err != nil { + t.Fatalf("failed to create semantic layer client: %v", err) + } + + prev := semanticLayerConfig + SetSemanticLayerConfig(&SemanticLayerConfig{Client: client}) + t.Cleanup(func() { semanticLayerConfig = prev }) + + return captured +} + +// stubResponses answers each GraphQL operation with the smallest payload the +// handlers will accept. The metric and dimension names are real ones, so a test +// asserting on them is asserting something the allowlist also permits. +var stubResponses = map[string]string{ + "GetMetrics": `{"data":{"metricsPaginated":{"items":[ + {"name":"total_contributors","label":"Contributors","description":"Contributor count","type":"simple"}, + {"name":"active_maintainers","label":"Active maintainers","description":"Maintainer count","type":"simple"}, + {"name":"current_membership_revenue","label":"Revenue","description":"Membership revenue","type":"simple"} + ]}}}`, + "GetMetricsWithRelated": `{"data":{"metricsPaginated":{"items":[ + {"name":"total_contributors","label":"Contributors","description":"Contributor count","type":"simple","dimensions":[{"name":"country__lf_region"}],"entities":[{"name":"country"}]}, + {"name":"active_maintainers","label":"Active maintainers","description":"Maintainer count","type":"simple","dimensions":[{"name":"country__lf_region"}],"entities":[]}, + {"name":"current_membership_revenue","label":"Revenue","description":"Membership revenue","type":"simple","dimensions":[{"name":"country__lf_region"}],"entities":[]} + ]}}}`, + "GetDimensions": `{"data":{"dimensionsPaginated":{"items":[ + {"name":"country__lf_region","type":"categorical","description":"Region","label":"Region","queryableGranularities":[]}, + {"name":"asset_id__membership_tier","type":"categorical","description":"Tier","label":"Tier","queryableGranularities":[]} + ]}}}`, + "CreateQuery": `{"data":{"createQuery":{"queryId":"q-1"}}}`, + "GetQueryResult": `{"data":{"query":{"status":"SUCCESSFUL","error":null,"sql":"SELECT 1", + "jsonResult":"{\"schema\":{\"fields\":[{\"name\":\"country__lf_region\",\"type\":\"string\"}],\"primaryKey\":[]},\"data\":[{\"country__lf_region\":\"Asia Pacific\"}]}"}}}`, +} + +// graphQLOperation extracts the operation name from a GraphQL document. +func graphQLOperation(query string) string { + for _, line := range strings.Split(query, "\n") { + line = strings.TrimSpace(line) + for _, prefix := range []string{"query ", "mutation "} { + if rest, found := strings.CutPrefix(line, prefix); found { + if idx := strings.IndexAny(rest, "( {"); idx > 0 { + return rest[:idx] + } + return rest + } + } + } + return "unknown" +} + +// schemaProperties returns the property names in a tool's input schema. +func schemaProperties(t *testing.T, tool *mcp.Tool) []string { + t.Helper() + raw, err := json.Marshal(tool.InputSchema) + if err != nil { + t.Fatalf("failed to marshal input schema: %v", err) + } + var schema struct { + Properties map[string]json.RawMessage `json:"properties"` + } + if err := json.Unmarshal(raw, &schema); err != nil { + t.Fatalf("failed to parse input schema: %v", err) + } + names := make([]string, 0, len(schema.Properties)) + for name := range schema.Properties { + names = append(names, name) + } + return names +} + +// --------------------------------------------------------------------------- +// Handler behaviour +// --------------------------------------------------------------------------- + +// TestSemanticLayerQueryReachesTheSemanticLayerDirectly is the point of the +// whole change: the handler talks to the dbt Semantic Layer itself, with no +// lfx-lens hop on the path. +func TestSemanticLayerQueryReachesTheSemanticLayerDirectly(t *testing.T) { + captured := setupSemanticLayerTest(t) + + res, _, err := handleQuerySemanticLayer(context.Background(), &mcp.CallToolRequest{}, QuerySemanticLayerArgs{ + Metrics: "active_maintainers", + GroupBy: "country__lf_region", + OrderBy: "-active_maintainers", + Limit: 10, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if res.IsError { + t.Fatalf("unexpected error result: %s", resultText(t, res)) + } + + vars, called := captured.operation("CreateQuery") + if !called { + t.Fatal("expected the handler to submit a query to the semantic layer") + } + + metrics, _ := vars["metrics"].([]any) + if len(metrics) != 1 { + t.Fatalf("expected one metric, got %v", vars["metrics"]) + } + if name, _ := metrics[0].(map[string]any)["name"].(string); name != "active_maintainers" { + t.Errorf("unexpected metric: %v", metrics[0]) + } + if vars["limit"] != float64(10) { + t.Errorf("expected the limit forwarded, got %v", vars["limit"]) + } + + orderBy, _ := vars["orderBy"].([]any) + if len(orderBy) != 1 { + t.Fatalf("expected one order term, got %v", vars["orderBy"]) + } + if desc, _ := orderBy[0].(map[string]any)["descending"].(bool); !desc { + t.Errorf("expected the - prefix to mean descending, got %v", orderBy[0]) + } +} + +// TestSemanticLayerQueryOmitsAnAbsentWhere keeps an empty filter from becoming +// an empty clause, which the semantic layer would reject. +func TestSemanticLayerQueryOmitsAnAbsentWhere(t *testing.T) { + captured := setupSemanticLayerTest(t) + + if _, _, err := handleQuerySemanticLayer(context.Background(), &mcp.CallToolRequest{}, QuerySemanticLayerArgs{ + Metrics: "active_maintainers", + }); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + vars, _ := captured.operation("CreateQuery") + if _, present := vars["where"]; present { + t.Errorf("expected where to be absent, got %v", vars["where"]) + } +} + +func TestSemanticLayerQueryForwardsAWhereClause(t *testing.T) { + captured := setupSemanticLayerTest(t) + + const filter = "{{ Dimension('maintainer_key__project_slug') }} = 'cncf'" + if _, _, err := handleQuerySemanticLayer(context.Background(), &mcp.CallToolRequest{}, QuerySemanticLayerArgs{ + Metrics: "active_maintainers", + Where: filter, + }); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + vars, _ := captured.operation("CreateQuery") + where, _ := vars["where"].([]any) + if len(where) != 1 { + t.Fatalf("expected one where clause, got %v", vars["where"]) + } + if sql, _ := where[0].(map[string]any)["sql"].(string); sql != filter { + t.Errorf("where clause = %q; want it forwarded verbatim", sql) + } +} + +// TestSemanticLayerQueryRejectsAMetricOutsideTheAllowlist keeps the allowlist +// enforced in-process, now that there is no lens route to enforce it. +func TestSemanticLayerQueryRejectsAMetricOutsideTheAllowlist(t *testing.T) { + captured := setupSemanticLayerTest(t) + + res, _, err := handleQuerySemanticLayer(context.Background(), &mcp.CallToolRequest{}, QuerySemanticLayerArgs{ + Metrics: "some_internal_metric", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !res.IsError { + t.Fatal("expected a metric outside the allowlist to be rejected") + } + if _, called := captured.operation("CreateQuery"); called { + t.Error("expected no query to reach the semantic layer") + } + if text := resultText(t, res); !strings.Contains(text, "list_metrics") { + t.Errorf("rejection should name the way forward: %q", text) + } +} + +func TestSemanticLayerListMetrics(t *testing.T) { + captured := setupSemanticLayerTest(t) + + res, _, err := handleExploreSemanticLayer(context.Background(), &mcp.CallToolRequest{}, ExploreSemanticLayerArgs{ + Action: "list_metrics", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if res.IsError { + t.Fatalf("unexpected error result: %s", resultText(t, res)) + } + if _, called := captured.operation("GetMetrics"); !called { + t.Error("expected the metrics metadata query to run") + } + if text := resultText(t, res); !strings.Contains(text, "total_contributors") { + t.Errorf("expected the metric list in the result: %q", text) + } +} + +func TestSemanticLayerGetDimensions(t *testing.T) { + captured := setupSemanticLayerTest(t) + + res, _, err := handleExploreSemanticLayer(context.Background(), &mcp.CallToolRequest{}, ExploreSemanticLayerArgs{ + Action: "get_dimensions", + Metrics: "active_maintainers", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if res.IsError { + t.Fatalf("unexpected error result: %s", resultText(t, res)) + } + if _, called := captured.operation("GetDimensions"); !called { + t.Error("expected the dimensions metadata query to run") + } +} + +// TestSemanticLayerGetDimensionsFiltersOnSearch covers the narrowing that the +// lens route used to do, since the upstream API has no dimension search. +func TestSemanticLayerGetDimensionsFiltersOnSearch(t *testing.T) { + setupSemanticLayerTest(t) + + res, _, err := handleExploreSemanticLayer(context.Background(), &mcp.CallToolRequest{}, ExploreSemanticLayerArgs{ + Action: "get_dimensions", + Metrics: "active_maintainers", + Search: "tier", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + text := resultText(t, res) + if !strings.Contains(text, "asset_id__membership_tier") { + t.Errorf("expected the matching dimension: %q", text) + } + if strings.Contains(text, "country__lf_region") { + t.Errorf("expected the non-matching dimension filtered out: %q", text) + } +} + +func TestSemanticLayerLimitTooLarge(t *testing.T) { + setupSemanticLayerTest(t) + + res, _, err := handleQuerySemanticLayer(context.Background(), &mcp.CallToolRequest{}, QuerySemanticLayerArgs{ + Metrics: "active_maintainers", + Limit: 501, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !res.IsError || resultText(t, res) != "Error: limit must be 500 or less" { + t.Errorf("expected limit error, got: %q (IsError=%v)", resultText(t, res), res.IsError) + } +} + +// TestSemanticLayerHelpQueryDescribesWhereScoping checks the help text moved +// off the removed scope parameter and onto the where clause. +func TestSemanticLayerHelpQueryDescribesWhereScoping(t *testing.T) { + setupSemanticLayerTest(t) + + res, _, err := handleExploreSemanticLayer(context.Background(), &mcp.CallToolRequest{}, ExploreSemanticLayerArgs{ + Action: "describe", + Target: "query", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + text := resultText(t, res) + if !strings.Contains(text, "there is no scope parameter") { + t.Errorf("query help should say scoping happens in where: %q", text) + } + if strings.Contains(text, "project_slug cncf") { + t.Errorf("query help still shows a project_slug argument: %q", text) + } +} + +// --------------------------------------------------------------------------- +// Description content +// --------------------------------------------------------------------------- + +// TestSemanticLayerDescriptions_FitSchemaBudget guards that limit for both +// semantic layer tools. Splitting discovery from querying gave each its own +// budget, which is the point of the split. +func TestSemanticLayerDescriptions_FitSchemaBudget(t *testing.T) { + for name, desc := range map[string]string{ + "explore_lfx_semantic_layer": exploreSemanticLayerDescription, + "query_lfx_semantic_layer": querySemanticLayerDescription, + } { + if got := len(desc); got > schemaDescriptionBudget { + t.Errorf("%s description is %d bytes; everything past %d is invisible to the model — move detail into help", + name, got, schemaDescriptionBudget) + } + } +} + +// TestExploreSemanticLayerDescription checks the discovery tool carries the +// routing contract: which domains are ours, and when to use query_lfx_lens. +func TestExploreSemanticLayerDescription(t *testing.T) { + for _, want := range []string{ + // The domains are named explicitly. Without them the routing is + // one-sided — query_lfx_lens lists concrete triggers while this tool + // describes itself abstractly, so every specific question looks like a + // better match for the other tool. + // Search terms must be words the Semantic Layer actually matches. + // The earlier headings were plurals — "contributions", "memberships", + // "education", "project health" all returned zero metrics, and a live + // client followed the instruction, got [], and fell back to + // query_lfx_lens. These singular forms are verified against the API. + "contributor, contribution —", + "membership, revenue, churn —", + "event, registration, speaker —", + "enrollment, certification —", + "maintainer —", + "health, project —", + // Regional questions route here for every topic, memberships included. + "any of the above sliced by country or region — always here, never query_lfx_lens", + // Dimension naming, and the regional person-vs-organization split. + "entity__field", + "country__lf_region", + "activity_project_id__organization_lf_region", + // Discovery must hand off to the query tool by name. + "query_lfx_semantic_layer", + // The value-discovery action, and the reason it exists. A filter naming + // a real dimension but an unknown literal returns zero rows instead of + // erroring, so a wrong guess is indistinguishable from missing data. A + // live client burned five query attempts on 'APAC' and 'Vietnam' before + // escaping via a country code. + "get_dimension_values(dimension, metrics, search)", + "returns zero rows, not an error", + "'Asia Pacific' not 'APAC'", + "'Viet Nam' not 'Vietnam'", + // Either tool can be loaded without the other, so each states what the + // semantic layer is. Here the regional rule sits in COVERS, asserted + // above, rather than in the opening line. + "query and data-exploration tool", + } { + if !strings.Contains(exploreSemanticLayerDescription, want) { + t.Errorf("explore description missing %q", want) + } + } + + // Event sponsorships stay with query_lfx_lens, which does them better, so + // this tool must not advertise them. Listing "sponsorship" as a topic here + // put two tools in charge of the same question and contradicted the + // carve-out query_lfx_lens still states. + if strings.Contains(exploreSemanticLayerDescription, "sponsorship,") { + t.Error("explore description claims sponsorships as a topic; query_lfx_lens owns them") + } + + // The description used to warn that a plural search matches nothing. That + // stopped being true once lens learned to fall back to a singular stem: + // "memberships" now returns 18 metrics, "contributions" 2. Telling the model + // otherwise wastes the budget on a false constraint. + for _, unwanted := range []string{ + "a plural like", + "matches nothing", + } { + if strings.Contains(exploreSemanticLayerDescription, unwanted) { + t.Errorf("explore description still warns about plurals, which lens now handles: %q", unwanted) + } + } +} + +// TestQuerySemanticLayerDescription checks the query tool is self-sufficient: +// its own description carries the syntax, so a caller never has to call help +// first. +func TestQuerySemanticLayerDescription(t *testing.T) { + for _, want := range []string{ + "metrics (required)", + "Dimension(", + "TimeDimension(", + "yyyy-mm-dd", + "ceiling 500", + "metric_time__year", + "The join is outer", + "ranked list", + // Splitting discovery out made it possible to query without ever + // exploring, and a live client did exactly that — going straight to a + // query with guessed names. The rule has to be an instruction, not a + // conditional suggestion. + "ALWAYS call explore_lfx_semantic_layer first", + "never guess", + // Both neighbours are named so routing works from this tool too. + "explore_lfx_semantic_layer", + "query_lfx_lens", + "query and data-exploration tool", + "anything sliced by country or region", + // The silent-zero-rows warning is only actionable if it names the way + // out; without this the model retries the same wrong literal. + "get_dimension_values", + } { + if !strings.Contains(querySemanticLayerDescription, want) { + t.Errorf("query description missing %q", want) + } + } + for _, unwanted := range []string{ + "MUST include a project scope filter", + // Scope validation is gone, so a promise to validate a project filter + // against a foundation subtree would now be a lie to the model. + "validated against that foundation", + // Framings that understate the tool and misroute the questions it + // exists to answer: it compiles SQL per request rather than serving + // stored rollups, and grouping by a name dimension returns lists of + // named organizations and people, not only figures. + "pre-aggregated", + "returns numbers, not records", + } { + if strings.Contains(querySemanticLayerDescription, unwanted) { + t.Errorf("query description must not contain %q", unwanted) + } + } +} + +// TestSemanticLayerArgs_FieldsFitSchemaBudget holds the other half of the +// budget contract: each property description is a separate field, so each must +// independently stay under the limit. +func TestSemanticLayerArgs_FieldsFitSchemaBudget(t *testing.T) { + for _, tc := range []struct { + tool *mcp.Tool + props []string + }{ + {listExploreTool(t), []string{"action", "search", "metrics", "dimension", "target"}}, + {listQueryTool(t), []string{"metrics", "group_by", "where", "order_by", "limit"}}, + } { + for _, property := range tc.props { + if got := len(schemaPropertyDescription(t, tc.tool, property)); got > schemaDescriptionBudget { + t.Errorf("%s.%s description is %d bytes; everything past %d is invisible to the model", + tc.tool.Name, property, got, schemaDescriptionBudget) + } + } + } +} + +// TestCriticalGuidanceSurvivesSchemaCompaction is the load-bearing test for +// where guidance is allowed to live. +// +// Clients that defer tool schemas behind a search index re-serialise them and +// replace OPTIONAL parameter descriptions with a short generated summary. This +// was verified against a live client: the 459-byte where description arrived as +// "Filter conditions.", order_by as "Sort order.", and limit as no description +// at all — until limit was temporarily marked required, at which point its real +// text appeared. Only the tool description and required parameters survive. +// +// So syntax the model cannot guess must not live solely on an optional +// parameter. Keeping the full text there is fine and useful for clients that do +// pass it through; it just may not be the only copy. +func TestCriticalGuidanceSurvivesSchemaCompaction(t *testing.T) { + var surviving string + for _, tool := range []*mcp.Tool{listExploreTool(t), listQueryTool(t)} { + surviving += "\n" + tool.Description + for _, name := range schemaRequired(t, tool) { + surviving += "\n" + schemaPropertyDescription(t, tool, name) + } + } + + for _, tc := range []struct { + token string + why string + }{ + {"Dimension(", "categorical filter syntax is unguessable"}, + {"TimeDimension(", "time filter syntax is unguessable"}, + {"yyyy-mm-dd", "date format silently returns wrong rows if guessed"}, + {"ceiling 500", "over-limit requests are rejected outright"}, + {"metric_time__year", "the only way to build a trend"}, + {"entity__field", "dimension names cannot be assembled by hand"}, + {"outer-joined", "explains NULLs in cross-domain results"}, + {"raw IDs", "grouping by an entity silently returns unusable output"}, + {"get_dimension_values", "the only recovery from a wrong filter literal"}, + {"zero rows", "a wrong literal is silent, so the model must be told to check first"}, + } { + if !strings.Contains(surviving, tc.token) { + t.Errorf("%q reaches the model only via an optional parameter, where it gets summarised away (%s). Move it into the tool description or onto a required parameter.", + tc.token, tc.why) + } + } +} + +func listExploreTool(t *testing.T) *mcp.Tool { + t.Helper() + return listRegisteredTool(t, "explore_lfx_semantic_layer", RegisterExploreSemanticLayer) +} + +func listQueryTool(t *testing.T) *mcp.Tool { + t.Helper() + return listRegisteredTool(t, "query_lfx_semantic_layer", RegisterQuerySemanticLayer) +} + +func TestRegisterSemanticLayer_Schema(t *testing.T) { + explore := listExploreTool(t) + query := listQueryTool(t) + + // Discovery: action is the only required field, and it must name exactly + // the actions the dispatcher accepts — a stale list sends the model to an + // action that errors. Querying lives on the other tool now. + exploreRequired := schemaRequired(t, explore) + if !contains(exploreRequired, "action") { + t.Errorf("explore required = %v; expected to contain action", exploreRequired) + } + if contains(exploreRequired, "metrics") { + t.Errorf("explore required = %v; metrics is only needed for get_dimensions", exploreRequired) + } + action := schemaPropertyDescription(t, explore, "action") + for _, want := range []string{"list_metrics", "get_dimensions", "get_dimension_values", "help"} { + if !strings.Contains(action, want) { + t.Errorf("action schema description missing %q: %q", want, action) + } + } + if strings.Contains(action, "describe") { + t.Errorf("action schema description still advertises the renamed describe action: %q", action) + } + + // Query: metrics is required, so its multi-metric join rules survive schema + // compaction. Everything else stays optional — above all project_slug, + // whose whole point is that global questions omit it. + queryRequired := schemaRequired(t, query) + if !contains(queryRequired, "metrics") { + t.Errorf("query required = %v; metrics must be required so its guidance survives compaction", queryRequired) + } + for _, optional := range []string{"where", "group_by", "order_by", "limit"} { + if contains(queryRequired, optional) { + t.Errorf("query required = %v; %s must stay optional", queryRequired, optional) + } + } + + // The optional descriptions are still expected to be complete, for clients + // that pass them through unchanged. + where := schemaPropertyDescription(t, query, "where") + for _, want := range []string{"Dimension(", "TimeDimension(", "yyyy-mm-dd"} { + if !strings.Contains(where, want) { + t.Errorf("where schema description missing %q: %q", want, where) + } + } + groupBy := schemaPropertyDescription(t, query, "group_by") + if !strings.Contains(groupBy, "metric_time__year") { + t.Errorf("group_by schema description missing the trend grain: %q", groupBy) + } + // There is no scope parameter any more: a caller restricts a query by + // putting the project filter in where, like any other filter. A leftover + // project_slug property would read as a scoping guarantee that nothing + // enforces. + if contains(schemaProperties(t, query), "project_slug") { + t.Error("query tool still exposes project_slug; scoping is done in the where clause now") + } +} + +// TestQueryToolRejectsMissingMetricsWithAPointer keeps the recovery path alive +// for a caller on a cached schema that still sends action=query here. +func TestQueryToolRejectsMissingMetricsWithAPointer(t *testing.T) { + setupSemanticLayerTest(t) + + res, _, err := handleQuerySemanticLayer(context.Background(), &mcp.CallToolRequest{}, QuerySemanticLayerArgs{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !res.IsError { + t.Fatal("expected an error result when metrics is empty") + } + if text := resultText(t, res); !strings.Contains(text, "explore_lfx_semantic_layer") { + t.Errorf("missing-metrics error should point at the discovery tool: %q", text) + } +} + +// TestExploreToolRedirectsQueryAction covers the other half of that migration: +// a caller still passing action=query to the discovery tool gets told where +// querying moved rather than a bare unknown-action error. +func TestExploreToolRedirectsQueryAction(t *testing.T) { + setupSemanticLayerTest(t) + + res, _, err := handleExploreSemanticLayer(context.Background(), &mcp.CallToolRequest{}, ExploreSemanticLayerArgs{ + Action: "query", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !res.IsError { + t.Fatal("expected an error result for action=query on the discovery tool") + } + if text := resultText(t, res); !strings.Contains(text, "query_lfx_semantic_layer") { + t.Errorf("redirect should name the query tool: %q", text) + } +} + +// TestHelpActionAndDescribeAlias checks the renamed action works and that the +// old action word still dispatches on this tool. It deliberately does NOT claim +// to cover the pre-split schema: that caller addresses query_lfx_semantic_layer, +// which no longer accepts an action, so no assertion here can exercise it. +func TestHelpActionAndDescribeAlias(t *testing.T) { + setupSemanticLayerTest(t) + + for _, action := range []string{"help", "describe"} { + res, _, err := handleExploreSemanticLayer(context.Background(), &mcp.CallToolRequest{}, ExploreSemanticLayerArgs{ + Action: action, + }) + if err != nil { + t.Fatalf("action %q: unexpected error: %v", action, err) + } + if text := resultText(t, res); !strings.Contains(text, "how to use it") { + t.Errorf("action %q did not return the help overview: %q", action, text) + } + } + + res, _, err := handleExploreSemanticLayer(context.Background(), &mcp.CallToolRequest{}, ExploreSemanticLayerArgs{ + Action: "help", + Target: "query", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + // The worked examples are the reason help exists; they must survive the + // move off the description. + if text := resultText(t, res); !strings.Contains(text, "metric_time__year") { + t.Errorf("help query text missing the trend example: %q", text) + } +} + +// TestUnknownActionListsTheRealActions guards the recovery message against +// drift: it is what a model reads after guessing an action name, so an action +// missing here is one it will not retry with. +func TestUnknownActionListsTheRealActions(t *testing.T) { + setupSemanticLayerTest(t) + + res, _, err := handleExploreSemanticLayer(context.Background(), &mcp.CallToolRequest{}, ExploreSemanticLayerArgs{ + Action: "list_dimension_values", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !res.IsError { + t.Fatal("expected an error result for an unknown action") + } + text := resultText(t, res) + for _, want := range []string{"list_metrics", "get_dimensions", "get_dimension_values", "help"} { + if !strings.Contains(text, want) { + t.Errorf("unknown-action error missing %q: %q", want, text) + } + } +} + +// TestHelpCoversGetDimensionValues checks the long-form guidance is reachable. +// It is the only place that records the billing_country trap, which has no room +// in the 2048-byte description. +func TestHelpCoversGetDimensionValues(t *testing.T) { + setupSemanticLayerTest(t) + + res, _, err := handleExploreSemanticLayer(context.Background(), &mcp.CallToolRequest{}, ExploreSemanticLayerArgs{ + Action: "help", + Target: "get_dimension_values", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if res.IsError { + t.Fatalf("unexpected error result: %s", resultText(t, res)) + } + text := resultText(t, res) + for _, want := range []string{ + "zero rows", + "'Asia Pacific'", + "Viet Nam", + // asset_id__billing_country is free text holding both spellings, so a + // filter on it drops members filed under the other one. The transcript + // that motivated this work "succeeded" on exactly that dimension. + "asset_id__billing_country", + } { + if !strings.Contains(text, want) { + t.Errorf("get_dimension_values help missing %q", want) + } + } + + // The overview must advertise the target, or nothing points at it. + overview, _, err := handleExploreSemanticLayer(context.Background(), &mcp.CallToolRequest{}, ExploreSemanticLayerArgs{ + Action: "help", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if text := resultText(t, overview); !strings.Contains(text, "get_dimension_values") { + t.Errorf("help overview does not mention get_dimension_values: %q", text) + } +} + +// TestSemanticLayerToolsRegisterIndependently guards tool selection. +// +// Both tools used to be added by one function behind one gate keyed to +// "query_lfx_semantic_layer", so LFXMCP_TOOLS=explore_lfx_semantic_layer +// registered nothing at all, and selecting only the query tool silently +// exposed both. Each name must control exactly its own tool. +func TestSemanticLayerToolsRegisterIndependently(t *testing.T) { + for _, tc := range []struct { + name string + register func(*mcp.Server) + absent string + }{ + {"explore_lfx_semantic_layer", RegisterExploreSemanticLayer, "query_lfx_semantic_layer"}, + {"query_lfx_semantic_layer", RegisterQuerySemanticLayer, "explore_lfx_semantic_layer"}, + } { + t.Run(tc.name, func(t *testing.T) { + if tool := listRegisteredTool(t, tc.name, tc.register); tool == nil { + t.Fatalf("%s did not register itself", tc.name) + } + if found := findRegisteredTool(t, tc.absent, tc.register); found != nil { + t.Errorf("registering %s also exposed %s; each name must select only its own tool", + tc.name, tc.absent) + } + }) + } +} From 8ce9c2fd3d4c8168b63d4351d7bb58b0079dcf97 Mon Sep 17 00:00:00 2001 From: Josep Garcia-Reyero Sais Date: Fri, 31 Jul 2026 15:56:55 +0200 Subject: [PATCH 3/8] feat(chart): expose the dbt Semantic Layer settings Adds LFXMCP_DBT_SL_HOST and LFXMCP_DBT_SL_ENVIRONMENT_ID as values, and LFXMCP_DBT_SL_TOKEN from the lfx-mcp-secrets Secret under the dbt_semantic_service_token key. No ExternalSecret change is needed: it already merges every AWS secret tagged service-lfx-mcp into that Secret, and the dbt token gains that tag in lfx-secrets-management (LFXV2-2939). The token env var is optional, so a cluster without the tag applied yet still starts, with the semantic layer tools reporting themselves unconfigured. Also documents the two data paths in AGENTS.md, since query_lfx_lens and the semantic layer tools no longer share a backend, and splits them in the README tool tables. Issue: LFXV2-2940 Signed-off-by: Josep Garcia-Reyero Sais --- AGENTS.md | 34 ++++++++++++++++++++++++ README.md | 19 +++++++++---- charts/lfx-mcp/templates/deployment.yaml | 14 ++++++++++ charts/lfx-mcp/values.yaml | 11 ++++++++ 4 files changed, 73 insertions(+), 5 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 800237e..2f0608b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,6 +26,7 @@ lfx-mcp/ │ └── lfx-mcp-server/ # Main application entry point ├── internal/ │ ├── auth/ # JWT and API-key verification +│ ├── dbtsl/ # dbt Semantic Layer client (GraphQL) │ ├── lfxv2/ # LFX V2 API client │ ├── otel/ # OpenTelemetry instrumentation │ ├── serviceapi/ # Shared service API helpers @@ -490,6 +491,39 @@ The server supports configuration via environment variables with the `LFXMCP_` p | `-onboarding_api_audience` | `LFXMCP_ONBOARDING_API_AUDIENCE` | — | Auth0 resource server audience for the member onboarding API | | `-lens_api_url` | `LFXMCP_LENS_API_URL` | — | Base URL of the LFX Lens service | | `-lens_api_audience` | `LFXMCP_LENS_API_AUDIENCE` | — | Auth0 resource server audience for the LFX Lens API | +| `-dbt_sl_host` | `LFXMCP_DBT_SL_HOST` | — | dbt Semantic Layer host, without scheme | +| `-dbt_sl_environment_id` | `LFXMCP_DBT_SL_ENVIRONMENT_ID` | — | dbt Semantic Layer environment ID | +| `-dbt_sl_token` | `LFXMCP_DBT_SL_TOKEN` | — | dbt Semantic Layer service token | + +### Two data paths, two services + +`query_lfx_lens` and the semantic layer tools answer overlapping questions but +do not share a backend, and the settings above reflect that: + +- **`query_lfx_lens`** posts a natural-language question to the LFX Lens + service, which generates SQL. It needs `LFXMCP_LENS_API_*`, and its client is + built through `internal/serviceapi` with an Auth0 client-credentials token. +- **`explore_lfx_semantic_layer` and `query_lfx_semantic_layer`** talk to the + dbt Semantic Layer directly through `internal/dbtsl`. They need + `LFXMCP_DBT_SL_*` and nothing else: a static service token, no Auth0. The + client is therefore constructed in its own top-level block in `main.go` + rather than inside the LFX API block, so it does not become unconfigured for + an unrelated reason. + +`internal/dbtsl` uses the GraphQL API for both metadata and query execution. +That diverges from the Python reference implementations (lfx-lens and dbt Labs' +`dbt-mcp`), which run queries over Arrow Flight through the `dbtsl` SDK; there +is no Go SDK, and Arrow plus gRPC buys nothing at a 500-row ceiling. A live +parity harness sits behind the `parity` build tag: + +```bash +set -a && source ../lfx-lens/.env && set +a +go test -tags parity -v ./internal/dbtsl/ +``` + +Do not pass the `serviceapi` debug transport to the dbt client. It dumps the +`Authorization` header, and production runs with `debugTraffic` enabled, so it +would print the long-lived service token into the logs. ## Error Handling Patterns diff --git a/README.md b/README.md index de1648a..83aa381 100644 --- a/README.md +++ b/README.md @@ -282,11 +282,20 @@ Hitting **Connect** will open a browser window for LFID login. ### LFX Lens -| Tool | Description | -|------------------------------|-------------------------------------------------------------------------------------------------------| -| `query_lfx_lens` | Ask natural-language questions about a project's data (events, contributors, health, value, and more) | -| `explore_lfx_semantic_layer` | Discover Insights metrics and the dimensions available to them | -| `query_lfx_semantic_layer` | Run a metric query against the Insights Semantic Layer (filter, group, rank, trend) | +| Tool | Description | +|------------------|-------------------------------------------------------------------------------------------------------| +| `query_lfx_lens` | Ask natural-language questions about a project's data (events, contributors, health, value, and more) | + +### Insights Semantic Layer + +Governed metrics with named dimensions, queried against the dbt Semantic Layer. +Prefer these over `query_lfx_lens` for anything that reduces to a metric: the +answer is repeatable and auditable, where generated SQL is neither. + +| Tool | Description | +|------------------------------|-------------------------------------------------------------------------------------| +| `explore_lfx_semantic_layer` | Discover metrics, the dimensions available to them, and the values a dimension holds | +| `query_lfx_semantic_layer` | Run a metric query (filter, group, rank, trend) | ### B2B Organizations diff --git a/charts/lfx-mcp/templates/deployment.yaml b/charts/lfx-mcp/templates/deployment.yaml index 1c44242..38d795d 100644 --- a/charts/lfx-mcp/templates/deployment.yaml +++ b/charts/lfx-mcp/templates/deployment.yaml @@ -80,6 +80,14 @@ spec: - name: LFXMCP_LENS_API_AUDIENCE value: {{ .Values.app.lensApiAudience | quote }} {{- end }} + {{- if .Values.app.dbtSlHost }} + - name: LFXMCP_DBT_SL_HOST + value: {{ .Values.app.dbtSlHost | quote }} + {{- end }} + {{- if .Values.app.dbtSemanticEnvironmentId }} + - name: LFXMCP_DBT_SL_ENVIRONMENT_ID + value: {{ .Values.app.dbtSemanticEnvironmentId | quote }} + {{- end }} {{- if .Values.app.memberOnboardingApiUrl }} - name: LFXMCP_ONBOARDING_API_URL value: {{ .Values.app.memberOnboardingApiUrl | quote }} @@ -105,6 +113,12 @@ spec: name: {{ .Values.secrets.name }} key: {{ .Values.secrets.keys.clientAssertionSigningKey }} optional: true + - name: LFXMCP_DBT_SL_TOKEN + valueFrom: + secretKeyRef: + name: {{ .Values.secrets.name }} + key: {{ .Values.secrets.keys.dbtSemanticServiceToken }} + optional: true {{- /* TEMPORARY: Expose each API key credential as an individual env var LFXMCP_API_CREDENTIALS_= sourced from the Secret referenced by diff --git a/charts/lfx-mcp/values.yaml b/charts/lfx-mcp/values.yaml index 7872b36..7c4ccae 100644 --- a/charts/lfx-mcp/values.yaml +++ b/charts/lfx-mcp/values.yaml @@ -100,6 +100,13 @@ app: lensApiUrl: "" # lensApiAudience is the Auth0 resource server audience for the LFX Lens API. lensApiAudience: "" + # dbtSlHost is the dbt Semantic Layer host, without scheme, + # e.g. tj283.semantic-layer.us1.dbt.com. Set it together with + # dbtSemanticEnvironmentId and the token; the semantic layer tools return an + # error when any of the three is missing. + dbtSlHost: "" + # dbtSemanticEnvironmentId is the dbt environment the semantic layer queries. + dbtSemanticEnvironmentId: "" # memberOnboardingApiUrl is the base URL of the member onboarding service. memberOnboardingApiUrl: "" # memberOnboardingApiAudience is the Auth0 resource server audience for the member onboarding API. @@ -147,6 +154,10 @@ secrets: # assertions (used when authenticating with a JWT client assertion instead of a # client secret). clientAssertionSigningKey: client_private_key + # dbtSemanticServiceToken is the key name for the dbt Semantic Layer service + # token. It arrives from the AWS Secrets Manager secret tagged + # service-lfx-mcp, which lfx-lens also reads. + dbtSemanticServiceToken: dbt_semantic_service_token # apiCredentials is a TEMPORARY stop-gap for MCP clients that cannot complete a full # OAuth2 authorization code flow. Remove this stanza once all clients support proper # OAuth2. From ca8fec30411ba9ba44b3e24a43e6284b4c14df33 Mon Sep 17 00:00:00 2001 From: Josep Garcia-Reyero Sais Date: Fri, 31 Jul 2026 17:50:36 +0200 Subject: [PATCH 4/8] fix(dbtsl): follow result pages, and bound the poll loop Review of the branch surfaced four defects, three of them found by running the code against the live semantic layer rather than a stub. Results were silently truncated. The GraphQL API pages at about 1024 rows where Arrow Flight, which the Python implementation used, streams the whole result, so this is a hazard the port introduced rather than inherited. createQuery only sent a limit when one was given, and the tool never defaulted it, so the common case of a model omitting limit issued an unbounded query. Live, 'total_activities grouped by organization_name' returned exactly 1024 rows and reported row_count 1024: a truncated answer indistinguishable from a complete one. Query now follows totalPages to the end, and the tool defaults an omitted or negative limit to the 500 its description advertises. The same query now returns 500. similarityRatio did not reproduce difflib. The j scan ran backwards, which is the compact way to write the rolling array but inverts difflib's tie-break: among equal-length runs it kept the latest j where difflib keeps the earliest, stranding the rest of a against a shorter tail. Measured against real difflib output, 25% of ordered pairs of allowlisted metric names disagreed. The point of hand-porting rather than using an edit distance was that suggestions would not change in the port, so this defeated the exercise. The test did not catch it because all eight hand-picked pairs happened to be ones where the tie-break does not bite; it is replaced by a sweep of all 3490 pairs against ratios generated by difflib itself, which fails on 879 of them against the old implementation. The poll loop had no bound of its own and no tolerance for a transient failure. stdio runs on a background context and the HTTP server sets only ReadHeaderTimeout, so a query stuck in RUNNING polled until the process exited; and a single 502 mid-poll discarded a query that was still running upstream, which the Python route had retried. Query now carries its own budget and absorbs a bounded number of consecutive transport errors, while a FAILED status still aborts at once. FetchDimensionValues accepted an empty metric list. Nothing in an empty list is disallowed, so it passed the allowlist check and then asked for dimensions scoped to no metric, which the live API answers with all 295 dimensions in the environment, including ones no allowlisted metric exposes. The only caller rejects an empty list first, so this was not reachable, but the gate is documented as living in the client. Also adds the per-file package comments the repo requires. Issue: LFXV2-2940 Signed-off-by: Josep Garcia-Reyero Sais --- internal/dbtsl/allowlist.go | 1 + internal/dbtsl/cache.go | 1 + internal/dbtsl/client.go | 11 +- internal/dbtsl/dbtsl_test.go | 196 ++++++++++++++++++-- internal/dbtsl/dimensionvalues.go | 10 + internal/dbtsl/metadata.go | 1 + internal/dbtsl/query.go | 109 +++++++++-- internal/dbtsl/search.go | 1 + internal/dbtsl/similarity.go | 31 +++- internal/dbtsl/testdata/difflib_ratios.json | 1 + internal/tools/csv.go | 1 + internal/tools/semanticlayer.go | 8 + internal/tools/semanticlayer_test.go | 30 +++ 13 files changed, 353 insertions(+), 48 deletions(-) create mode 100644 internal/dbtsl/testdata/difflib_ratios.json diff --git a/internal/dbtsl/allowlist.go b/internal/dbtsl/allowlist.go index 023225d..9827b41 100644 --- a/internal/dbtsl/allowlist.go +++ b/internal/dbtsl/allowlist.go @@ -1,6 +1,7 @@ // Copyright The Linux Foundation and contributors. // SPDX-License-Identifier: MIT +// Package dbtsl provides a client for the dbt Semantic Layer API. package dbtsl import ( diff --git a/internal/dbtsl/cache.go b/internal/dbtsl/cache.go index 3e052ed..ce6629b 100644 --- a/internal/dbtsl/cache.go +++ b/internal/dbtsl/cache.go @@ -1,6 +1,7 @@ // Copyright The Linux Foundation and contributors. // SPDX-License-Identifier: MIT +// Package dbtsl provides a client for the dbt Semantic Layer API. package dbtsl import ( diff --git a/internal/dbtsl/client.go b/internal/dbtsl/client.go index 0430242..0e48247 100644 --- a/internal/dbtsl/client.go +++ b/internal/dbtsl/client.go @@ -12,9 +12,14 @@ // dbtsl SDK and split the transport: GraphQL for metadata, Arrow Flight over // gRPC for execution. There is no Go SDK for the dbt Semantic Layer, and // reproducing the Flight path would mean taking on Arrow, gRPC and session -// lifecycle for no benefit at the volumes this server queries. Callers are -// capped at 500 rows, comfortably inside the GraphQL API's 1024-row page, so -// pagination never engages. +// lifecycle for no benefit at the volumes this server queries. +// +// The one thing the GraphQL transport does not give away for free is +// completeness. Flight streams a whole result; GraphQL pages it at about 1024 +// rows, and a caller that ignores the paging gets page one and no indication +// there was ever a page two. Query therefore follows totalPages to the end +// (see query.go), and the tool layer defaults an unspecified limit to its +// advertised ceiling rather than sending none. // // Access to metrics is gated by an allowlist (see allowlist.go). Dimension // value discovery is gated on the caller supplying the metrics the dimension diff --git a/internal/dbtsl/dbtsl_test.go b/internal/dbtsl/dbtsl_test.go index 305e736..802e90f 100644 --- a/internal/dbtsl/dbtsl_test.go +++ b/internal/dbtsl/dbtsl_test.go @@ -7,9 +7,12 @@ import ( "context" "encoding/json" "errors" + "fmt" "io" "net/http" "net/http/httptest" + "os" + "path/filepath" "strings" "testing" "time" @@ -185,28 +188,46 @@ func TestNoMetricsDetailNamesTopicsAndTheDimensionTrap(t *testing.T) { // Similarity, mirroring difflib.SequenceMatcher.ratio() // --------------------------------------------------------------------------- +// TestSimilarityRatioMatchesDifflib checks similarityRatio against every +// ordered pair of allowlisted metric names, plus a handful of short +// adversarial pairs. +// +// The fixture holds ratios produced by Python's difflib itself: +// +// python3 -c 'import difflib; print(difflib.SequenceMatcher(None, a, b).ratio())' +// +// An earlier version of this test used eight hand-picked pairs and passed +// while the implementation disagreed with difflib on a quarter of the real +// domain: every hand-picked pair happened to be one where the tie-break did +// not bite. Sweeping the domain is the only version of this test that would +// have caught it, and it still runs in milliseconds. func TestSimilarityRatioMatchesDifflib(t *testing.T) { - tests := []struct { - a, b string - want float64 - }{ - {"", "", 1}, - {"abcd", "abcd", 1}, - {"abcd", "bcde", 0.75}, // longest run "bcd", 2*3/8 - {"abc", "xyz", 0}, // nothing in common - {"ab", "abcdef", 0.5}, // 2*2/8 - // Only single-character runs match, and the algorithm commits to the - // earliest one rather than the one that would score best overall. - {"tide", "diet", 0.25}, - // Two values from the domain, as regression anchors. - {"contributor_count", "total_contributors", 0.6285714285714286}, - {"membership", "memberships", 0.9523809523809523}, + raw, err := os.ReadFile(filepath.Join("testdata", "difflib_ratios.json")) + if err != nil { + t.Fatalf("reading fixture: %v", err) } - for _, tc := range tests { - if got := similarityRatio(tc.a, tc.b); !nearlyEqual(got, tc.want) { - t.Errorf("similarityRatio(%q, %q) = %v, want %v", tc.a, tc.b, got, tc.want) + var cases [][]any + if err := json.Unmarshal(raw, &cases); err != nil { + t.Fatalf("parsing fixture: %v", err) + } + if len(cases) < 3000 { + t.Fatalf("fixture has only %d pairs, expected the full allowlist sweep", len(cases)) + } + + mismatched := 0 + for _, c := range cases { + a, b, want := c[0].(string), c[1].(string), c[2].(float64) + got := similarityRatio(a, b) + if !nearlyEqual(got, want) { + mismatched++ + if mismatched <= 5 { + t.Errorf("similarityRatio(%q, %q) = %v, difflib = %v", a, b, got, want) + } } } + if mismatched > 0 { + t.Errorf("%d of %d pairs disagree with difflib", mismatched, len(cases)) + } } func nearlyEqual(a, b float64) bool { @@ -429,6 +450,121 @@ func TestQueryStopsWhenTheContextIsCancelled(t *testing.T) { } } +// pagedResultJSON builds a SUCCESSFUL response holding one row, declaring +// totalPages so the pagination loop has something to follow. +func pagedResultJSON(region string, totalPages int) string { + return fmt.Sprintf( + `{"data":{"query":{"status":"SUCCESSFUL","error":null,"sql":"SELECT 1","totalPages":%d,`+ + `"jsonResult":"{\"schema\":{\"fields\":[{\"name\":\"index\",\"type\":\"integer\"},`+ + `{\"name\":\"country__lf_region\",\"type\":\"string\"}],\"primaryKey\":[\"index\"]},`+ + `\"data\":[{\"index\":0,\"country__lf_region\":\"%s\"}]}"}}}`, + totalPages, region) +} + +// TestQueryFetchesEveryResultPage guards against silently truncating a result. +// +// The GraphQL API pages at about 1024 rows, where the Arrow Flight transport +// the Python implementation used streamed the whole result. Before this was +// handled, an unbounded live query came back with exactly 1024 rows and a +// row_count of 1024, indistinguishable from a complete answer. +func TestQueryFetchesEveryResultPage(t *testing.T) { + stub := newStubServer(t) + stub.queue("CreateQuery", `{"data":{"createQuery":{"queryId":"q-1"}}}`) + stub.queue("GetQueryResult", pagedResultJSON("Asia Pacific", 3)) + stub.queue("GetQueryResult", pagedResultJSON("Europe", 3)) + stub.queue("GetQueryResult", pagedResultJSON("North America", 3)) + + client := stub.client(t) + result, err := client.Query(context.Background(), QueryArgs{Metrics: []string{"total_contributors"}}) + if err != nil { + t.Fatalf("Query failed: %v", err) + } + if result.RowCount != 3 { + t.Errorf("expected all 3 pages concatenated, got row_count %d", result.RowCount) + } + if len(result.Data) != 3 { + t.Fatalf("expected 3 rows, got %d", len(result.Data)) + } + for i, want := range []string{"Asia Pacific", "Europe", "North America"} { + if got := result.Data[i]["country__lf_region"]; got != want { + t.Errorf("row %d = %v, want %q", i, got, want) + } + } +} + +// TestQueryRequestsThePageItIsAskingFor checks the pageNum argument is sent, +// since without it the API silently returns page 1 every time. +func TestQueryRequestsThePageItIsAskingFor(t *testing.T) { + stub := newStubServer(t) + stub.queue("CreateQuery", `{"data":{"createQuery":{"queryId":"q-1"}}}`) + stub.queue("GetQueryResult", pagedResultJSON("Asia Pacific", 2)) + stub.queue("GetQueryResult", pagedResultJSON("Europe", 2)) + + client := stub.client(t) + if _, err := client.Query(context.Background(), QueryArgs{Metrics: []string{"total_contributors"}}); err != nil { + t.Fatalf("Query failed: %v", err) + } + if got := stub.lastVariables()["pageNum"]; got != float64(2) { + t.Errorf("expected the second page requested, got pageNum %v", got) + } +} + +// TestQueryToleratesATransientPollFailure: a gateway error mid-poll says +// nothing about the query, which is still running upstream. The Python route +// retried once on transport errors, and dropping that made a 502 discard a +// query that was about to succeed. +func TestQueryToleratesATransientPollFailure(t *testing.T) { + stub := newStubServer(t) + stub.queue("CreateQuery", `{"data":{"createQuery":{"queryId":"q-1"}}}`) + stub.queue("GetQueryResult", `{"errors":[{"message":"502 Bad Gateway"}]}`) + stub.queue("GetQueryResult", successfulResultJSON) + + client := stub.client(t) + result, err := client.Query(context.Background(), QueryArgs{Metrics: []string{"total_contributors"}}) + if err != nil { + t.Fatalf("expected the transient failure absorbed, got %v", err) + } + if result.RowCount != 1 { + t.Errorf("expected 1 row, got %d", result.RowCount) + } +} + +// TestQueryGivesUpAfterRepeatedPollFailures: tolerance is bounded, so a +// genuinely broken endpoint still ends the call. +func TestQueryGivesUpAfterRepeatedPollFailures(t *testing.T) { + stub := newStubServer(t) + stub.queue("CreateQuery", `{"data":{"createQuery":{"queryId":"q-1"}}}`) + stub.queue("GetQueryResult", `{"errors":[{"message":"502 Bad Gateway"}]}`) + + client := stub.client(t) + _, err := client.Query(context.Background(), QueryArgs{Metrics: []string{"total_contributors"}}) + if err == nil { + t.Fatal("expected the query to give up on a persistently failing endpoint") + } + if !strings.Contains(err.Error(), "502 Bad Gateway") { + t.Errorf("expected the underlying reason preserved, got %v", err) + } + if stub.calls["GetQueryResult"] != maxConsecutivePollFailures { + t.Errorf("expected %d attempts, got %d", maxConsecutivePollFailures, stub.calls["GetQueryResult"]) + } +} + +// A FAILED status is an application error, not a transport one, so it must +// abort immediately rather than consume the transient-failure budget. +func TestQueryDoesNotRetryAnApplicationFailure(t *testing.T) { + stub := newStubServer(t) + stub.queue("CreateQuery", `{"data":{"createQuery":{"queryId":"q-1"}}}`) + stub.queue("GetQueryResult", `{"data":{"query":{"status":"FAILED","error":"Unable to resolve metric","sql":null,"jsonResult":null}}}`) + + client := stub.client(t) + if _, err := client.Query(context.Background(), QueryArgs{Metrics: []string{"total_contributors"}}); err == nil { + t.Fatal("expected a failure") + } + if stub.calls["GetQueryResult"] != 1 { + t.Errorf("expected a single poll, got %d", stub.calls["GetQueryResult"]) + } +} + func TestQuerySurfacesGraphQLErrors(t *testing.T) { stub := newStubServer(t) stub.queue("CreateQuery", `{"errors":[{"message":"Metric 'nope' not found"}]}`) @@ -465,6 +601,30 @@ func TestFetchDimensionValuesRejectsAnInjectionShapedName(t *testing.T) { } } +// TestFetchDimensionValuesRejectsAnEmptyMetricList closes the hole in the +// gate. An empty list has nothing disallowed in it, so it passed the allowlist +// check and then asked for dimensions scoped to no metric at all, which the +// live API answers with all 295 dimensions in the environment. The only +// caller rejects an empty list first, but the gate is documented as living +// here, so it has to hold here. +func TestFetchDimensionValuesRejectsAnEmptyMetricList(t *testing.T) { + for _, metrics := range [][]string{nil, {}, {"", " "}} { + stub := newStubServer(t) + client := stub.client(t) + + _, err := client.FetchDimensionValues(context.Background(), + "country__lf_region", metrics, "", 100) + + var unknown *UnknownDimensionError + if !errors.As(err, &unknown) { + t.Fatalf("metrics %q: expected an UnknownDimensionError, got %v", metrics, err) + } + if len(stub.requests) != 0 { + t.Errorf("metrics %q: expected rejection before any request was made", metrics) + } + } +} + func TestFetchDimensionValuesRejectsAMetricOutsideTheAllowlist(t *testing.T) { stub := newStubServer(t) client := stub.client(t) diff --git a/internal/dbtsl/dimensionvalues.go b/internal/dbtsl/dimensionvalues.go index 931968d..1630a10 100644 --- a/internal/dbtsl/dimensionvalues.go +++ b/internal/dbtsl/dimensionvalues.go @@ -1,6 +1,7 @@ // Copyright The Linux Foundation and contributors. // SPDX-License-Identifier: MIT +// Package dbtsl provides a client for the dbt Semantic Layer API. package dbtsl import ( @@ -76,6 +77,15 @@ func (c *Client) FetchDimensionValues(ctx context.Context, dimension string, met )} } + // An empty list has nothing disallowed in it, so it would pass the check + // below and then ask for dimensions scoped to no metric at all, which the + // API answers with every dimension in the environment (295 of them here, + // including ones no allowlisted metric exposes). Require the gate to have + // something to check before checking it. + if len(normalizeMetricNames(metricNames)) == 0 { + return nil, &UnknownDimensionError{Message: "At least one metric is required. Dimension values are checked against the metrics you intend to query; pass the metric from list_metrics."} + } + if disallowed := ValidateMetrics(metricNames); len(disallowed) > 0 { return nil, &UnknownDimensionError{Message: fmt.Sprintf( "Metrics not in allowlist: %s.", strings.Join(disallowed, ", "), diff --git a/internal/dbtsl/metadata.go b/internal/dbtsl/metadata.go index c92f5b3..10aca3b 100644 --- a/internal/dbtsl/metadata.go +++ b/internal/dbtsl/metadata.go @@ -1,6 +1,7 @@ // Copyright The Linux Foundation and contributors. // SPDX-License-Identifier: MIT +// Package dbtsl provides a client for the dbt Semantic Layer API. package dbtsl import ( diff --git a/internal/dbtsl/query.go b/internal/dbtsl/query.go index 392688b..0d8afe2 100644 --- a/internal/dbtsl/query.go +++ b/internal/dbtsl/query.go @@ -1,6 +1,7 @@ // Copyright The Linux Foundation and contributors. // SPDX-License-Identifier: MIT +// Package dbtsl provides a client for the dbt Semantic Layer API. package dbtsl import ( @@ -22,11 +23,12 @@ mutation CreateQuery($environmentId: BigInt!, $metrics: [MetricInput!], $groupBy ` const gqlQueryResult = ` -query GetQueryResult($environmentId: BigInt!, $queryId: String!) { - query(environmentId: $environmentId, queryId: $queryId) { +query GetQueryResult($environmentId: BigInt!, $queryId: String!, $pageNum: Int!) { + query(environmentId: $environmentId, queryId: $queryId, pageNum: $pageNum) { status error sql + totalPages jsonResult(encoded: false, orient: TABLE) } } @@ -34,14 +36,34 @@ query GetQueryResult($environmentId: BigInt!, $queryId: String!) { // Query polling cadence. The Semantic Layer compiles and runs warehouse SQL, // so the first result is rarely ready immediately. The interval backs off so a -// slow query does not generate a poll storm, and the overall bound comes from -// the caller's context. +// slow query does not generate a poll storm. const ( pollInitialInterval = 250 * time.Millisecond pollMaxInterval = 2 * time.Second pollBackoffFactor = 1.5 ) +// queryMaxWait bounds a single Query call end to end. +// +// The caller's context is not a sufficient bound on its own: stdio mode runs +// on a background context, and in HTTP mode main.go sets only +// ReadHeaderTimeout, so nothing else stops a query that sits in RUNNING +// forever from holding a goroutine and polling until the process exits. The +// value is well clear of the slowest query observed against this environment +// (a year-over-year trend at about 23 seconds). +const queryMaxWait = 5 * time.Minute + +// maxConsecutivePollFailures is how many transport errors in a row the poll +// loop absorbs before giving up. +// +// A single 502 from the gateway mid-poll says nothing about the query, which +// is still running upstream and may be about to succeed. The Python +// implementation this was ported from retried once on transport errors while +// failing fast on application errors (semantic_layer_routes.py), and the same +// split applies here: a FAILED status still aborts immediately. The counter +// resets on any successful poll. +const maxConsecutivePollFailures = 3 + // Query status values returned by the Semantic Layer. Anything else means the // query is still in flight. const ( @@ -117,6 +139,7 @@ type queryResultResponse struct { Status string `json:"status"` Error string `json:"error"` SQL string `json:"sql"` + TotalPages int `json:"totalPages"` JSONResult string `json:"jsonResult"` } `json:"query"` } @@ -127,27 +150,78 @@ type queryResultResponse struct { // successful or failed. Results come back as JSON rather than Arrow, which // keeps this client free of an Arrow and gRPC dependency. func (c *Client) Query(ctx context.Context, args QueryArgs) (*QueryResult, error) { + ctx, cancel := context.WithTimeout(ctx, queryMaxWait) + defer cancel() + queryID, err := c.createQuery(ctx, args) if err != nil { return nil, err } - interval := pollInitialInterval - for { - result, err := c.pollQuery(ctx, queryID) + first, err := c.awaitQuery(ctx, queryID) + if err != nil { + return nil, err + } + + result, err := parseQueryResult(first.Query.JSONResult, first.Query.SQL) + if err != nil { + return nil, err + } + + // The GraphQL API pages results at about 1024 rows. Arrow Flight, which + // the Python implementation used, streamed the whole result, so this is a + // truncation risk the port introduced rather than inherited: without this + // loop an unbounded query returns page 1 and reports its length as the + // row count, and the caller reads 1024 as the answer. Callers are capped + // well inside a single page, so this rarely engages, but dropping rows + // silently is the wrong way to be wrong. + for page := 2; page <= first.Query.TotalPages; page++ { + next, err := c.pollQuery(ctx, queryID, page) + if err != nil { + return nil, fmt.Errorf("fetching result page %d of %d: %w", page, first.Query.TotalPages, err) + } + more, err := parseQueryResult(next.Query.JSONResult, "") if err != nil { return nil, err } + result.Data = append(result.Data, more.Data...) + } + result.RowCount = len(result.Data) + + return result, nil +} + +// awaitQuery polls a submitted query until the Semantic Layer reports it +// successful or failed, and returns its first page. +func (c *Client) awaitQuery(ctx context.Context, queryID string) (*queryResultResponse, error) { + interval := pollInitialInterval + failures := 0 - switch result.Query.Status { - case statusSuccessful: - return parseQueryResult(result.Query.JSONResult, result.Query.SQL) - case statusFailed: - message := strings.TrimSpace(result.Query.Error) - if message == "" { - message = "the semantic layer reported the query failed but gave no reason" + for { + result, err := c.pollQuery(ctx, queryID, 1) + switch { + case err == nil: + failures = 0 + switch result.Query.Status { + case statusSuccessful: + return result, nil + case statusFailed: + message := strings.TrimSpace(result.Query.Error) + if message == "" { + message = "the semantic layer reported the query failed but gave no reason" + } + return nil, &QueryFailedError{Message: message} + } + case ctx.Err() != nil: + // Out of budget, or the caller went away. Report that rather than + // the transport error it surfaced as. + return nil, fmt.Errorf("semantic layer query did not finish in time: %w", ctx.Err()) + default: + // A transport error says nothing about the query, which is still + // running upstream. Absorb a few before giving up. + if failures++; failures >= maxConsecutivePollFailures { + return nil, fmt.Errorf("semantic layer polling failed %d times in a row: %w", failures, err) } - return nil, &QueryFailedError{Message: message} } select { @@ -195,9 +269,10 @@ func (c *Client) createQuery(ctx context.Context, args QueryArgs) (string, error return resp.CreateQuery.QueryID, nil } -func (c *Client) pollQuery(ctx context.Context, queryID string) (*queryResultResponse, error) { +func (c *Client) pollQuery(ctx context.Context, queryID string, pageNum int) (*queryResultResponse, error) { var resp queryResultResponse - if err := c.graphqlRequest(ctx, gqlQueryResult, map[string]any{"queryId": queryID}, &resp); err != nil { + variables := map[string]any{"queryId": queryID, "pageNum": pageNum} + if err := c.graphqlRequest(ctx, gqlQueryResult, variables, &resp); err != nil { return nil, err } return &resp, nil diff --git a/internal/dbtsl/search.go b/internal/dbtsl/search.go index 6e4e18d..82f3ee0 100644 --- a/internal/dbtsl/search.go +++ b/internal/dbtsl/search.go @@ -1,6 +1,7 @@ // Copyright The Linux Foundation and contributors. // SPDX-License-Identifier: MIT +// Package dbtsl provides a client for the dbt Semantic Layer API. package dbtsl import "strings" diff --git a/internal/dbtsl/similarity.go b/internal/dbtsl/similarity.go index 50f81df..60da017 100644 --- a/internal/dbtsl/similarity.go +++ b/internal/dbtsl/similarity.go @@ -1,6 +1,7 @@ // Copyright The Linux Foundation and contributors. // SPDX-License-Identifier: MIT +// Package dbtsl provides a client for the dbt Semantic Layer API. package dbtsl // similarityRatio returns the Ratcliff/Obershelp similarity of a and b, in @@ -45,29 +46,39 @@ func matchingRunes(a, b []rune) int { // // Ties resolve to the earliest run in a, then the earliest in b, matching // difflib's behaviour. +// The j scan runs forwards, which is what makes the tie-break match. difflib +// walks the positions of each character in b in ascending order and keeps a +// run only when it is strictly longer, so among equal-length runs the +// earliest j wins. Scanning j backwards over a single rolling array is the +// more compact way to write this, but it inverts that rule and keeps the +// latest j instead, which strands the rest of a against a shorter tail and +// makes the recursion undercount. Two rows cost one extra allocation and are +// worth it. func longestCommonRun(a, b []rune) (aStart, bStart, length int) { - // runLengths[j] is the length of the common run ending at a[i], b[j] for - // the row being scanned. It is rebuilt per row from its previous values, - // walking j backwards so each read happens before it is overwritten. - runLengths := make([]int, len(b)) + // prev and cur hold, for the previous and current row, the length of the + // common run ending at a[i], b[j]. Every j is assigned on every row, so + // no stale values survive the swap. + prev := make([]int, len(b)) + cur := make([]int, len(b)) for i := range a { - for j := len(b) - 1; j >= 0; j-- { + for j := range b { if a[i] != b[j] { - runLengths[j] = 0 + cur[j] = 0 continue } if j == 0 { - runLengths[j] = 1 + cur[j] = 1 } else { - runLengths[j] = runLengths[j-1] + 1 + cur[j] = prev[j-1] + 1 } - if runLengths[j] > length { - length = runLengths[j] + if cur[j] > length { + length = cur[j] aStart = i - length + 1 bStart = j - length + 1 } } + prev, cur = cur, prev } return aStart, bStart, length } diff --git a/internal/dbtsl/testdata/difflib_ratios.json b/internal/dbtsl/testdata/difflib_ratios.json new file mode 100644 index 0000000..f6f183f --- /dev/null +++ b/internal/dbtsl/testdata/difflib_ratios.json @@ -0,0 +1 @@ +[["active_maintainer_records", "active_maintainer_records", 1.0], ["active_maintainer_records", "active_maintainers", 0.8372093023255814], ["active_maintainer_records", "approved_pull_requests", 0.3404255319148936], ["active_maintainer_records", "avg_project_health_score", 0.32653061224489793], ["active_maintainer_records", "bot_activities", 0.358974358974359], ["active_maintainer_records", "certification_enrollments", 0.32], ["active_maintainer_records", "churned_membership_count", 0.2857142857142857], ["active_maintainer_records", "churned_membership_discount_amount", 0.3050847457627119], ["active_maintainer_records", "churned_membership_invoice_amount", 0.27586206896551724], ["active_maintainer_records", "code_contribution_activities", 0.2641509433962264], ["active_maintainer_records", "current_membership_count", 0.2857142857142857], ["active_maintainer_records", "current_membership_discount_amount", 0.3050847457627119], ["active_maintainer_records", "current_membership_invoice_amount", 0.3103448275862069], ["active_maintainer_records", "current_membership_revenue", 0.35294117647058826], ["active_maintainer_records", "current_new_account_membership_count", 0.26229508196721313], ["active_maintainer_records", "human_activities", 0.34146341463414637], ["active_maintainer_records", "last_completed_year_active_discount_amount", 0.3582089552238806], ["active_maintainer_records", "last_completed_year_active_invoice_amount", 0.30303030303030304], ["active_maintainer_records", "last_completed_year_active_membership_count", 0.3235294117647059], ["active_maintainer_records", "last_completed_year_active_membership_revenue", 0.37142857142857144], ["active_maintainer_records", "lf_project_activities", 0.30434782608695654], ["active_maintainer_records", "main_branch_commits", 0.45454545454545453], ["active_maintainer_records", "membership_revenue", 0.32558139534883723], ["active_maintainer_records", "past_event_speakers", 0.45454545454545453], ["active_maintainer_records", "past_events_count", 0.42857142857142855], ["active_maintainer_records", "project_count", 0.2631578947368421], ["active_maintainer_records", "project_health_count", 0.26666666666666666], ["active_maintainer_records", "renewal_price", 0.2631578947368421], ["active_maintainer_records", "sponsorship_quantity_total", 0.23529411764705882], ["active_maintainer_records", "total_accepted_proposals", 0.2857142857142857], ["active_maintainer_records", "total_activities", 0.34146341463414637], ["active_maintainer_records", "total_certifications", 0.35555555555555557], ["active_maintainer_records", "total_code_deletions", 0.26666666666666666], ["active_maintainer_records", "total_code_insertions", 0.2608695652173913], ["active_maintainer_records", "total_contributing_organizations", 0.2807017543859649], ["active_maintainer_records", "total_contributors", 0.37209302325581395], ["active_maintainer_records", "total_discount_amount", 0.2608695652173913], ["active_maintainer_records", "total_downgrade_churn_amount", 0.22641509433962265], ["active_maintainer_records", "total_enrolled_users", 0.3111111111111111], ["active_maintainer_records", "total_enrollments", 0.2857142857142857], ["active_maintainer_records", "total_estimated_cost", 0.4888888888888889], ["active_maintainer_records", "total_event_registrations_goal", 0.36363636363636365], ["active_maintainer_records", "total_events", 0.32432432432432434], ["active_maintainer_records", "total_first_time_contributors", 0.4074074074074074], ["active_maintainer_records", "total_gross_revenue", 0.3181818181818182], ["active_maintainer_records", "total_invoice_amount", 0.3111111111111111], ["active_maintainer_records", "total_maintainer_records", 0.8163265306122449], ["active_maintainer_records", "total_maintainers", 0.6190476190476191], ["active_maintainer_records", "total_next_membership_revenue", 0.3333333333333333], ["active_maintainer_records", "total_registration_net_revenue", 0.2909090909090909], ["active_maintainer_records", "total_registration_tax", 0.2978723404255319], ["active_maintainer_records", "total_registrations", 0.36363636363636365], ["active_maintainer_records", "total_software_value", 0.26666666666666666], ["active_maintainer_records", "total_speakers", 0.3076923076923077], ["active_maintainer_records", "total_speaking_engagements", 0.27450980392156865], ["active_maintainer_records", "total_sponsorship_count", 0.16666666666666666], ["active_maintainer_records", "total_sponsorship_revenue", 0.28], ["active_maintainer_records", "training_enrollments", 0.4444444444444444], ["active_maintainer_records", "upcoming_events_count", 0.391304347826087], ["active_maintainers", "active_maintainer_records", 0.8372093023255814], ["active_maintainers", "active_maintainers", 1.0], ["active_maintainers", "approved_pull_requests", 0.3], ["active_maintainers", "avg_project_health_score", 0.2857142857142857], ["active_maintainers", "bot_activities", 0.4375], ["active_maintainers", "certification_enrollments", 0.37209302325581395], ["active_maintainers", "churned_membership_count", 0.3333333333333333], ["active_maintainers", "churned_membership_discount_amount", 0.2692307692307692], ["active_maintainers", "churned_membership_invoice_amount", 0.27450980392156865], ["active_maintainers", "code_contribution_activities", 0.30434782608695654], ["active_maintainers", "current_membership_count", 0.3333333333333333], ["active_maintainers", "current_membership_discount_amount", 0.2692307692307692], ["active_maintainers", "current_membership_invoice_amount", 0.27450980392156865], ["active_maintainers", "current_membership_revenue", 0.3181818181818182], ["active_maintainers", "current_new_account_membership_count", 0.2962962962962963], ["active_maintainers", "human_activities", 0.4117647058823529], ["active_maintainers", "last_completed_year_active_discount_amount", 0.4], ["active_maintainers", "last_completed_year_active_invoice_amount", 0.3389830508474576], ["active_maintainers", "last_completed_year_active_membership_count", 0.36065573770491804], ["active_maintainers", "last_completed_year_active_membership_revenue", 0.3492063492063492], ["active_maintainers", "lf_project_activities", 0.358974358974359], ["active_maintainers", "main_branch_commits", 0.32432432432432434], ["active_maintainers", "membership_revenue", 0.2777777777777778], ["active_maintainers", "past_event_speakers", 0.5405405405405406], ["active_maintainers", "past_events_count", 0.4], ["active_maintainers", "project_count", 0.3225806451612903], ["active_maintainers", "project_health_count", 0.3157894736842105], ["active_maintainers", "renewal_price", 0.25806451612903225], ["active_maintainers", "sponsorship_quantity_total", 0.2727272727272727], ["active_maintainers", "total_accepted_proposals", 0.3333333333333333], ["active_maintainers", "total_activities", 0.4117647058823529], ["active_maintainers", "total_certifications", 0.42105263157894735], ["active_maintainers", "total_code_deletions", 0.3157894736842105], ["active_maintainers", "total_code_insertions", 0.3076923076923077], ["active_maintainers", "total_contributing_organizations", 0.36], ["active_maintainers", "total_contributors", 0.3888888888888889], ["active_maintainers", "total_discount_amount", 0.3076923076923077], ["active_maintainers", "total_downgrade_churn_amount", 0.2608695652173913], ["active_maintainers", "total_enrolled_users", 0.3684210526315789], ["active_maintainers", "total_enrollments", 0.34285714285714286], ["active_maintainers", "total_estimated_cost", 0.42105263157894735], ["active_maintainers", "total_event_registrations_goal", 0.20833333333333334], ["active_maintainers", "total_events", 0.4], ["active_maintainers", "total_first_time_contributors", 0.425531914893617], ["active_maintainers", "total_gross_revenue", 0.2702702702702703], ["active_maintainers", "total_invoice_amount", 0.3684210526315789], ["active_maintainers", "total_maintainer_records", 0.6190476190476191], ["active_maintainers", "total_maintainers", 0.7428571428571429], ["active_maintainers", "total_next_membership_revenue", 0.2978723404255319], ["active_maintainers", "total_registration_net_revenue", 0.2916666666666667], ["active_maintainers", "total_registration_tax", 0.3], ["active_maintainers", "total_registrations", 0.2702702702702703], ["active_maintainers", "total_software_value", 0.3157894736842105], ["active_maintainers", "total_speakers", 0.375], ["active_maintainers", "total_speaking_engagements", 0.3181818181818182], ["active_maintainers", "total_sponsorship_count", 0.1951219512195122], ["active_maintainers", "total_sponsorship_revenue", 0.27906976744186046], ["active_maintainers", "training_enrollments", 0.47368421052631576], ["active_maintainers", "upcoming_events_count", 0.358974358974359], ["approved_pull_requests", "active_maintainer_records", 0.3404255319148936], ["approved_pull_requests", "active_maintainers", 0.3], ["approved_pull_requests", "approved_pull_requests", 1.0], ["approved_pull_requests", "avg_project_health_score", 0.43478260869565216], ["approved_pull_requests", "bot_activities", 0.2222222222222222], ["approved_pull_requests", "certification_enrollments", 0.3404255319148936], ["approved_pull_requests", "churned_membership_count", 0.30434782608695654], ["approved_pull_requests", "churned_membership_discount_amount", 0.32142857142857145], ["approved_pull_requests", "churned_membership_invoice_amount", 0.2545454545454545], ["approved_pull_requests", "code_contribution_activities", 0.16], ["approved_pull_requests", "current_membership_count", 0.21739130434782608], ["approved_pull_requests", "current_membership_discount_amount", 0.17857142857142858], ["approved_pull_requests", "current_membership_invoice_amount", 0.18181818181818182], ["approved_pull_requests", "current_membership_revenue", 0.25], ["approved_pull_requests", "current_new_account_membership_count", 0.2413793103448276], ["approved_pull_requests", "human_activities", 0.21052631578947367], ["approved_pull_requests", "last_completed_year_active_discount_amount", 0.28125], ["approved_pull_requests", "last_completed_year_active_invoice_amount", 0.2222222222222222], ["approved_pull_requests", "last_completed_year_active_membership_count", 0.24615384615384617], ["approved_pull_requests", "last_completed_year_active_membership_revenue", 0.3283582089552239], ["approved_pull_requests", "lf_project_activities", 0.27906976744186046], ["approved_pull_requests", "main_branch_commits", 0.24390243902439024], ["approved_pull_requests", "membership_revenue", 0.3], ["approved_pull_requests", "past_event_speakers", 0.34146341463414637], ["approved_pull_requests", "past_events_count", 0.2564102564102564], ["approved_pull_requests", "project_count", 0.4], ["approved_pull_requests", "project_health_count", 0.3333333333333333], ["approved_pull_requests", "renewal_price", 0.22857142857142856], ["approved_pull_requests", "sponsorship_quantity_total", 0.25], ["approved_pull_requests", "total_accepted_proposals", 0.34782608695652173], ["approved_pull_requests", "total_activities", 0.2631578947368421], ["approved_pull_requests", "total_certifications", 0.23809523809523808], ["approved_pull_requests", "total_code_deletions", 0.2857142857142857], ["approved_pull_requests", "total_code_insertions", 0.23255813953488372], ["approved_pull_requests", "total_contributing_organizations", 0.2222222222222222], ["approved_pull_requests", "total_contributors", 0.3], ["approved_pull_requests", "total_discount_amount", 0.23255813953488372], ["approved_pull_requests", "total_downgrade_churn_amount", 0.28], ["approved_pull_requests", "total_enrolled_users", 0.42857142857142855], ["approved_pull_requests", "total_enrollments", 0.41025641025641024], ["approved_pull_requests", "total_estimated_cost", 0.2857142857142857], ["approved_pull_requests", "total_event_registrations_goal", 0.34615384615384615], ["approved_pull_requests", "total_events", 0.29411764705882354], ["approved_pull_requests", "total_first_time_contributors", 0.27450980392156865], ["approved_pull_requests", "total_gross_revenue", 0.3902439024390244], ["approved_pull_requests", "total_invoice_amount", 0.2857142857142857], ["approved_pull_requests", "total_maintainer_records", 0.2608695652173913], ["approved_pull_requests", "total_maintainers", 0.2564102564102564], ["approved_pull_requests", "total_next_membership_revenue", 0.27450980392156865], ["approved_pull_requests", "total_registration_net_revenue", 0.2692307692307692], ["approved_pull_requests", "total_registration_tax", 0.3181818181818182], ["approved_pull_requests", "total_registrations", 0.3902439024390244], ["approved_pull_requests", "total_software_value", 0.3333333333333333], ["approved_pull_requests", "total_speakers", 0.2777777777777778], ["approved_pull_requests", "total_speaking_engagements", 0.2916666666666667], ["approved_pull_requests", "total_sponsorship_count", 0.26666666666666666], ["approved_pull_requests", "total_sponsorship_revenue", 0.3404255319148936], ["approved_pull_requests", "training_enrollments", 0.38095238095238093], ["approved_pull_requests", "upcoming_events_count", 0.27906976744186046], ["avg_project_health_score", "active_maintainer_records", 0.3673469387755102], ["avg_project_health_score", "active_maintainers", 0.2857142857142857], ["avg_project_health_score", "approved_pull_requests", 0.43478260869565216], ["avg_project_health_score", "avg_project_health_score", 1.0], ["avg_project_health_score", "bot_activities", 0.2631578947368421], ["avg_project_health_score", "certification_enrollments", 0.2857142857142857], ["avg_project_health_score", "churned_membership_count", 0.20833333333333334], ["avg_project_health_score", "churned_membership_discount_amount", 0.20689655172413793], ["avg_project_health_score", "churned_membership_invoice_amount", 0.10526315789473684], ["avg_project_health_score", "code_contribution_activities", 0.19230769230769232], ["avg_project_health_score", "current_membership_count", 0.3333333333333333], ["avg_project_health_score", "current_membership_discount_amount", 0.3103448275862069], ["avg_project_health_score", "current_membership_invoice_amount", 0.2807017543859649], ["avg_project_health_score", "current_membership_revenue", 0.32], ["avg_project_health_score", "current_new_account_membership_count", 0.3], ["avg_project_health_score", "human_activities", 0.3], ["avg_project_health_score", "last_completed_year_active_discount_amount", 0.30303030303030304], ["avg_project_health_score", "last_completed_year_active_invoice_amount", 0.3076923076923077], ["avg_project_health_score", "last_completed_year_active_membership_count", 0.3283582089552239], ["avg_project_health_score", "last_completed_year_active_membership_revenue", 0.3188405797101449], ["avg_project_health_score", "lf_project_activities", 0.4888888888888889], ["avg_project_health_score", "main_branch_commits", 0.37209302325581395], ["avg_project_health_score", "membership_revenue", 0.14285714285714285], ["avg_project_health_score", "past_event_speakers", 0.27906976744186046], ["avg_project_health_score", "past_events_count", 0.3902439024390244], ["avg_project_health_score", "project_count", 0.5405405405405406], ["avg_project_health_score", "project_health_count", 0.7727272727272727], ["avg_project_health_score", "renewal_price", 0.2702702702702703], ["avg_project_health_score", "sponsorship_quantity_total", 0.24], ["avg_project_health_score", "total_accepted_proposals", 0.3333333333333333], ["avg_project_health_score", "total_activities", 0.3], ["avg_project_health_score", "total_certifications", 0.2727272727272727], ["avg_project_health_score", "total_code_deletions", 0.36363636363636365], ["avg_project_health_score", "total_code_insertions", 0.35555555555555557], ["avg_project_health_score", "total_contributing_organizations", 0.17857142857142858], ["avg_project_health_score", "total_contributors", 0.38095238095238093], ["avg_project_health_score", "total_discount_amount", 0.35555555555555557], ["avg_project_health_score", "total_downgrade_churn_amount", 0.19230769230769232], ["avg_project_health_score", "total_enrolled_users", 0.36363636363636365], ["avg_project_health_score", "total_enrollments", 0.34146341463414637], ["avg_project_health_score", "total_estimated_cost", 0.36363636363636365], ["avg_project_health_score", "total_event_registrations_goal", 0.2962962962962963], ["avg_project_health_score", "total_events", 0.3333333333333333], ["avg_project_health_score", "total_first_time_contributors", 0.37735849056603776], ["avg_project_health_score", "total_gross_revenue", 0.32558139534883723], ["avg_project_health_score", "total_invoice_amount", 0.22727272727272727], ["avg_project_health_score", "total_maintainer_records", 0.375], ["avg_project_health_score", "total_maintainers", 0.2926829268292683], ["avg_project_health_score", "total_next_membership_revenue", 0.33962264150943394], ["avg_project_health_score", "total_registration_net_revenue", 0.2962962962962963], ["avg_project_health_score", "total_registration_tax", 0.30434782608695654], ["avg_project_health_score", "total_registrations", 0.32558139534883723], ["avg_project_health_score", "total_software_value", 0.4090909090909091], ["avg_project_health_score", "total_speakers", 0.3157894736842105], ["avg_project_health_score", "total_speaking_engagements", 0.24], ["avg_project_health_score", "total_sponsorship_count", 0.3404255319148936], ["avg_project_health_score", "total_sponsorship_revenue", 0.3673469387755102], ["avg_project_health_score", "training_enrollments", 0.36363636363636365], ["avg_project_health_score", "upcoming_events_count", 0.3111111111111111], ["bot_activities", "active_maintainer_records", 0.5128205128205128], ["bot_activities", "active_maintainers", 0.625], ["bot_activities", "approved_pull_requests", 0.2222222222222222], ["bot_activities", "avg_project_health_score", 0.3157894736842105], ["bot_activities", "bot_activities", 1.0], ["bot_activities", "certification_enrollments", 0.41025641025641024], ["bot_activities", "churned_membership_count", 0.15789473684210525], ["bot_activities", "churned_membership_discount_amount", 0.25], ["bot_activities", "churned_membership_invoice_amount", 0.2127659574468085], ["bot_activities", "code_contribution_activities", 0.6190476190476191], ["bot_activities", "current_membership_count", 0.21052631578947367], ["bot_activities", "current_membership_discount_amount", 0.25], ["bot_activities", "current_membership_invoice_amount", 0.1702127659574468], ["bot_activities", "current_membership_revenue", 0.25], ["bot_activities", "current_new_account_membership_count", 0.28], ["bot_activities", "human_activities", 0.7333333333333333], ["bot_activities", "last_completed_year_active_discount_amount", 0.35714285714285715], ["bot_activities", "last_completed_year_active_invoice_amount", 0.36363636363636365], ["bot_activities", "last_completed_year_active_membership_count", 0.3508771929824561], ["bot_activities", "last_completed_year_active_membership_revenue", 0.3389830508474576], ["bot_activities", "lf_project_activities", 0.7428571428571429], ["bot_activities", "main_branch_commits", 0.30303030303030304], ["bot_activities", "membership_revenue", 0.25], ["bot_activities", "past_event_speakers", 0.30303030303030304], ["bot_activities", "past_events_count", 0.25806451612903225], ["bot_activities", "project_count", 0.37037037037037035], ["bot_activities", "project_health_count", 0.35294117647058826], ["bot_activities", "renewal_price", 0.2222222222222222], ["bot_activities", "sponsorship_quantity_total", 0.15], ["bot_activities", "total_accepted_proposals", 0.42105263157894735], ["bot_activities", "total_activities", 0.8666666666666667], ["bot_activities", "total_certifications", 0.5882352941176471], ["bot_activities", "total_code_deletions", 0.4117647058823529], ["bot_activities", "total_code_insertions", 0.4], ["bot_activities", "total_contributing_organizations", 0.43478260869565216], ["bot_activities", "total_contributors", 0.5], ["bot_activities", "total_discount_amount", 0.2857142857142857], ["bot_activities", "total_downgrade_churn_amount", 0.23809523809523808], ["bot_activities", "total_enrolled_users", 0.29411764705882354], ["bot_activities", "total_enrollments", 0.3225806451612903], ["bot_activities", "total_estimated_cost", 0.47058823529411764], ["bot_activities", "total_event_registrations_goal", 0.3181818181818182], ["bot_activities", "total_events", 0.38461538461538464], ["bot_activities", "total_first_time_contributors", 0.37209302325581395], ["bot_activities", "total_gross_revenue", 0.30303030303030304], ["bot_activities", "total_invoice_amount", 0.29411764705882354], ["bot_activities", "total_maintainer_records", 0.3157894736842105], ["bot_activities", "total_maintainers", 0.5161290322580645], ["bot_activities", "total_next_membership_revenue", 0.32558139534883723], ["bot_activities", "total_registration_net_revenue", 0.36363636363636365], ["bot_activities", "total_registration_tax", 0.3888888888888889], ["bot_activities", "total_registrations", 0.42424242424242425], ["bot_activities", "total_software_value", 0.35294117647058826], ["bot_activities", "total_speakers", 0.42857142857142855], ["bot_activities", "total_speaking_engagements", 0.3], ["bot_activities", "total_sponsorship_count", 0.2702702702702703], ["bot_activities", "total_sponsorship_revenue", 0.3076923076923077], ["bot_activities", "training_enrollments", 0.17647058823529413], ["bot_activities", "upcoming_events_count", 0.2857142857142857], ["certification_enrollments", "active_maintainer_records", 0.28], ["certification_enrollments", "active_maintainers", 0.18604651162790697], ["certification_enrollments", "approved_pull_requests", 0.3404255319148936], ["certification_enrollments", "avg_project_health_score", 0.24489795918367346], ["certification_enrollments", "bot_activities", 0.41025641025641024], ["certification_enrollments", "certification_enrollments", 1.0], ["certification_enrollments", "churned_membership_count", 0.32653061224489793], ["certification_enrollments", "churned_membership_discount_amount", 0.3050847457627119], ["certification_enrollments", "churned_membership_invoice_amount", 0.20689655172413793], ["certification_enrollments", "code_contribution_activities", 0.41509433962264153], ["certification_enrollments", "current_membership_count", 0.2857142857142857], ["certification_enrollments", "current_membership_discount_amount", 0.23728813559322035], ["certification_enrollments", "current_membership_invoice_amount", 0.2413793103448276], ["certification_enrollments", "current_membership_revenue", 0.27450980392156865], ["certification_enrollments", "current_new_account_membership_count", 0.22950819672131148], ["certification_enrollments", "human_activities", 0.3902439024390244], ["certification_enrollments", "last_completed_year_active_discount_amount", 0.29850746268656714], ["certification_enrollments", "last_completed_year_active_invoice_amount", 0.18181818181818182], ["certification_enrollments", "last_completed_year_active_membership_count", 0.23529411764705882], ["certification_enrollments", "last_completed_year_active_membership_revenue", 0.22857142857142856], ["certification_enrollments", "lf_project_activities", 0.34782608695652173], ["certification_enrollments", "main_branch_commits", 0.36363636363636365], ["certification_enrollments", "membership_revenue", 0.32558139534883723], ["certification_enrollments", "past_event_speakers", 0.3181818181818182], ["certification_enrollments", "past_events_count", 0.3333333333333333], ["certification_enrollments", "project_count", 0.2631578947368421], ["certification_enrollments", "project_health_count", 0.2222222222222222], ["certification_enrollments", "renewal_price", 0.10526315789473684], ["certification_enrollments", "sponsorship_quantity_total", 0.19607843137254902], ["certification_enrollments", "total_accepted_proposals", 0.32653061224489793], ["certification_enrollments", "total_activities", 0.3902439024390244], ["certification_enrollments", "total_certifications", 0.6222222222222222], ["certification_enrollments", "total_code_deletions", 0.3111111111111111], ["certification_enrollments", "total_code_insertions", 0.34782608695652173], ["certification_enrollments", "total_contributing_organizations", 0.38596491228070173], ["certification_enrollments", "total_contributors", 0.27906976744186046], ["certification_enrollments", "total_discount_amount", 0.17391304347826086], ["certification_enrollments", "total_downgrade_churn_amount", 0.2641509433962264], ["certification_enrollments", "total_enrolled_users", 0.4888888888888889], ["certification_enrollments", "total_enrollments", 0.6666666666666666], ["certification_enrollments", "total_estimated_cost", 0.3111111111111111], ["certification_enrollments", "total_event_registrations_goal", 0.4], ["certification_enrollments", "total_events", 0.43243243243243246], ["certification_enrollments", "total_first_time_contributors", 0.3333333333333333], ["certification_enrollments", "total_gross_revenue", 0.18181818181818182], ["certification_enrollments", "total_invoice_amount", 0.26666666666666666], ["certification_enrollments", "total_maintainer_records", 0.24489795918367346], ["certification_enrollments", "total_maintainers", 0.14285714285714285], ["certification_enrollments", "total_next_membership_revenue", 0.25925925925925924], ["certification_enrollments", "total_registration_net_revenue", 0.4], ["certification_enrollments", "total_registration_tax", 0.3829787234042553], ["certification_enrollments", "total_registrations", 0.36363636363636365], ["certification_enrollments", "total_software_value", 0.13333333333333333], ["certification_enrollments", "total_speakers", 0.15384615384615385], ["certification_enrollments", "total_speaking_engagements", 0.43137254901960786], ["certification_enrollments", "total_sponsorship_count", 0.3333333333333333], ["certification_enrollments", "total_sponsorship_revenue", 0.32], ["certification_enrollments", "training_enrollments", 0.7111111111111111], ["certification_enrollments", "upcoming_events_count", 0.391304347826087], ["churned_membership_count", "active_maintainer_records", 0.2857142857142857], ["churned_membership_count", "active_maintainers", 0.3333333333333333], ["churned_membership_count", "approved_pull_requests", 0.34782608695652173], ["churned_membership_count", "avg_project_health_score", 0.2916666666666667], ["churned_membership_count", "bot_activities", 0.15789473684210525], ["churned_membership_count", "certification_enrollments", 0.32653061224489793], ["churned_membership_count", "churned_membership_count", 1.0], ["churned_membership_count", "churned_membership_discount_amount", 0.8275862068965517], ["churned_membership_count", "churned_membership_invoice_amount", 0.8421052631578947], ["churned_membership_count", "code_contribution_activities", 0.2692307692307692], ["churned_membership_count", "current_membership_count", 0.875], ["churned_membership_count", "current_membership_discount_amount", 0.7241379310344828], ["churned_membership_count", "current_membership_invoice_amount", 0.7368421052631579], ["churned_membership_count", "current_membership_revenue", 0.68], ["churned_membership_count", "current_new_account_membership_count", 0.7333333333333333], ["churned_membership_count", "human_activities", 0.25], ["churned_membership_count", "last_completed_year_active_discount_amount", 0.36363636363636365], ["churned_membership_count", "last_completed_year_active_invoice_amount", 0.27692307692307694], ["churned_membership_count", "last_completed_year_active_membership_count", 0.5970149253731343], ["churned_membership_count", "last_completed_year_active_membership_revenue", 0.463768115942029], ["churned_membership_count", "lf_project_activities", 0.13333333333333333], ["churned_membership_count", "main_branch_commits", 0.27906976744186046], ["churned_membership_count", "membership_revenue", 0.5714285714285714], ["churned_membership_count", "past_event_speakers", 0.23255813953488372], ["churned_membership_count", "past_events_count", 0.3902439024390244], ["churned_membership_count", "project_count", 0.3783783783783784], ["churned_membership_count", "project_health_count", 0.45454545454545453], ["churned_membership_count", "renewal_price", 0.2702702702702703], ["churned_membership_count", "sponsorship_quantity_total", 0.4], ["churned_membership_count", "total_accepted_proposals", 0.25], ["churned_membership_count", "total_activities", 0.15], ["churned_membership_count", "total_certifications", 0.18181818181818182], ["churned_membership_count", "total_code_deletions", 0.18181818181818182], ["churned_membership_count", "total_code_insertions", 0.2222222222222222], ["churned_membership_count", "total_contributing_organizations", 0.17857142857142858], ["churned_membership_count", "total_contributors", 0.23809523809523808], ["churned_membership_count", "total_discount_amount", 0.3111111111111111], ["churned_membership_count", "total_downgrade_churn_amount", 0.4230769230769231], ["churned_membership_count", "total_enrolled_users", 0.3181818181818182], ["churned_membership_count", "total_enrollments", 0.24390243902439024], ["churned_membership_count", "total_estimated_cost", 0.2727272727272727], ["churned_membership_count", "total_event_registrations_goal", 0.14814814814814814], ["churned_membership_count", "total_events", 0.2222222222222222], ["churned_membership_count", "total_first_time_contributors", 0.33962264150943394], ["churned_membership_count", "total_gross_revenue", 0.09302325581395349], ["churned_membership_count", "total_invoice_amount", 0.36363636363636365], ["churned_membership_count", "total_maintainer_records", 0.25], ["churned_membership_count", "total_maintainers", 0.24390243902439024], ["churned_membership_count", "total_next_membership_revenue", 0.5660377358490566], ["churned_membership_count", "total_registration_net_revenue", 0.25925925925925924], ["churned_membership_count", "total_registration_tax", 0.17391304347826086], ["churned_membership_count", "total_registrations", 0.13953488372093023], ["churned_membership_count", "total_software_value", 0.09090909090909091], ["churned_membership_count", "total_speakers", 0.21052631578947367], ["churned_membership_count", "total_speaking_engagements", 0.24], ["churned_membership_count", "total_sponsorship_count", 0.5106382978723404], ["churned_membership_count", "total_sponsorship_revenue", 0.32653061224489793], ["churned_membership_count", "training_enrollments", 0.3181818181818182], ["churned_membership_count", "upcoming_events_count", 0.4888888888888889], ["churned_membership_discount_amount", "active_maintainer_records", 0.23728813559322035], ["churned_membership_discount_amount", "active_maintainers", 0.2692307692307692], ["churned_membership_discount_amount", "approved_pull_requests", 0.2857142857142857], ["churned_membership_discount_amount", "avg_project_health_score", 0.2413793103448276], ["churned_membership_discount_amount", "bot_activities", 0.25], ["churned_membership_discount_amount", "certification_enrollments", 0.2711864406779661], ["churned_membership_discount_amount", "churned_membership_count", 0.8275862068965517], ["churned_membership_discount_amount", "churned_membership_discount_amount", 1.0], ["churned_membership_discount_amount", "churned_membership_invoice_amount", 0.835820895522388], ["churned_membership_discount_amount", "code_contribution_activities", 0.22580645161290322], ["churned_membership_discount_amount", "current_membership_count", 0.7241379310344828], ["churned_membership_discount_amount", "current_membership_discount_amount", 0.9117647058823529], ["churned_membership_discount_amount", "current_membership_invoice_amount", 0.746268656716418], ["churned_membership_discount_amount", "current_membership_revenue", 0.5666666666666667], ["churned_membership_discount_amount", "current_new_account_membership_count", 0.6285714285714286], ["churned_membership_discount_amount", "human_activities", 0.24], ["churned_membership_discount_amount", "last_completed_year_active_discount_amount", 0.5789473684210527], ["churned_membership_discount_amount", "last_completed_year_active_invoice_amount", 0.4266666666666667], ["churned_membership_discount_amount", "last_completed_year_active_membership_count", 0.5194805194805194], ["churned_membership_discount_amount", "last_completed_year_active_membership_revenue", 0.4050632911392405], ["churned_membership_discount_amount", "lf_project_activities", 0.18181818181818182], ["churned_membership_discount_amount", "main_branch_commits", 0.22641509433962265], ["churned_membership_discount_amount", "membership_revenue", 0.46153846153846156], ["churned_membership_discount_amount", "past_event_speakers", 0.18867924528301888], ["churned_membership_discount_amount", "past_events_count", 0.27450980392156865], ["churned_membership_discount_amount", "project_count", 0.2978723404255319], ["churned_membership_discount_amount", "project_health_count", 0.3333333333333333], ["churned_membership_discount_amount", "renewal_price", 0.2127659574468085], ["churned_membership_discount_amount", "sponsorship_quantity_total", 0.4], ["churned_membership_discount_amount", "total_accepted_proposals", 0.2413793103448276], ["churned_membership_discount_amount", "total_activities", 0.2], ["churned_membership_discount_amount", "total_certifications", 0.14814814814814814], ["churned_membership_discount_amount", "total_code_deletions", 0.2222222222222222], ["churned_membership_discount_amount", "total_code_insertions", 0.18181818181818182], ["churned_membership_discount_amount", "total_contributing_organizations", 0.2727272727272727], ["churned_membership_discount_amount", "total_contributors", 0.15384615384615385], ["churned_membership_discount_amount", "total_discount_amount", 0.5818181818181818], ["churned_membership_discount_amount", "total_downgrade_churn_amount", 0.3870967741935484], ["churned_membership_discount_amount", "total_enrolled_users", 0.25925925925925924], ["churned_membership_discount_amount", "total_enrollments", 0.19607843137254902], ["churned_membership_discount_amount", "total_estimated_cost", 0.2222222222222222], ["churned_membership_discount_amount", "total_event_registrations_goal", 0.25], ["churned_membership_discount_amount", "total_events", 0.17391304347826086], ["churned_membership_discount_amount", "total_first_time_contributors", 0.2222222222222222], ["churned_membership_discount_amount", "total_gross_revenue", 0.07547169811320754], ["churned_membership_discount_amount", "total_invoice_amount", 0.3333333333333333], ["churned_membership_discount_amount", "total_maintainer_records", 0.20689655172413793], ["churned_membership_discount_amount", "total_maintainers", 0.19607843137254902], ["churned_membership_discount_amount", "total_next_membership_revenue", 0.47619047619047616], ["churned_membership_discount_amount", "total_registration_net_revenue", 0.1875], ["churned_membership_discount_amount", "total_registration_tax", 0.2857142857142857], ["churned_membership_discount_amount", "total_registrations", 0.22641509433962265], ["churned_membership_discount_amount", "total_software_value", 0.07407407407407407], ["churned_membership_discount_amount", "total_speakers", 0.16666666666666666], ["churned_membership_discount_amount", "total_speaking_engagements", 0.2], ["churned_membership_discount_amount", "total_sponsorship_count", 0.42105263157894735], ["churned_membership_discount_amount", "total_sponsorship_revenue", 0.2711864406779661], ["churned_membership_discount_amount", "training_enrollments", 0.25925925925925924], ["churned_membership_discount_amount", "upcoming_events_count", 0.32727272727272727], ["churned_membership_invoice_amount", "active_maintainer_records", 0.1724137931034483], ["churned_membership_invoice_amount", "active_maintainers", 0.27450980392156865], ["churned_membership_invoice_amount", "approved_pull_requests", 0.2909090909090909], ["churned_membership_invoice_amount", "avg_project_health_score", 0.14035087719298245], ["churned_membership_invoice_amount", "bot_activities", 0.2127659574468085], ["churned_membership_invoice_amount", "certification_enrollments", 0.27586206896551724], ["churned_membership_invoice_amount", "churned_membership_count", 0.8421052631578947], ["churned_membership_invoice_amount", "churned_membership_discount_amount", 0.835820895522388], ["churned_membership_invoice_amount", "churned_membership_invoice_amount", 1.0], ["churned_membership_invoice_amount", "code_contribution_activities", 0.22950819672131148], ["churned_membership_invoice_amount", "current_membership_count", 0.7368421052631579], ["churned_membership_invoice_amount", "current_membership_discount_amount", 0.746268656716418], ["churned_membership_invoice_amount", "current_membership_invoice_amount", 0.9090909090909091], ["churned_membership_invoice_amount", "current_membership_revenue", 0.6101694915254238], ["churned_membership_invoice_amount", "current_new_account_membership_count", 0.6376811594202898], ["churned_membership_invoice_amount", "human_activities", 0.24489795918367346], ["churned_membership_invoice_amount", "last_completed_year_active_discount_amount", 0.4], ["churned_membership_invoice_amount", "last_completed_year_active_invoice_amount", 0.5675675675675675], ["churned_membership_invoice_amount", "last_completed_year_active_membership_count", 0.5263157894736842], ["churned_membership_invoice_amount", "last_completed_year_active_membership_revenue", 0.4358974358974359], ["churned_membership_invoice_amount", "lf_project_activities", 0.14814814814814814], ["churned_membership_invoice_amount", "main_branch_commits", 0.23076923076923078], ["churned_membership_invoice_amount", "membership_revenue", 0.5098039215686274], ["churned_membership_invoice_amount", "past_event_speakers", 0.19230769230769232], ["churned_membership_invoice_amount", "past_events_count", 0.2], ["churned_membership_invoice_amount", "project_count", 0.30434782608695654], ["churned_membership_invoice_amount", "project_health_count", 0.33962264150943394], ["churned_membership_invoice_amount", "renewal_price", 0.34782608695652173], ["churned_membership_invoice_amount", "sponsorship_quantity_total", 0.3389830508474576], ["churned_membership_invoice_amount", "total_accepted_proposals", 0.24561403508771928], ["churned_membership_invoice_amount", "total_activities", 0.16326530612244897], ["churned_membership_invoice_amount", "total_certifications", 0.33962264150943394], ["churned_membership_invoice_amount", "total_code_deletions", 0.22641509433962265], ["churned_membership_invoice_amount", "total_code_insertions", 0.25925925925925924], ["churned_membership_invoice_amount", "total_contributing_organizations", 0.27692307692307694], ["churned_membership_invoice_amount", "total_contributors", 0.1568627450980392], ["churned_membership_invoice_amount", "total_discount_amount", 0.37037037037037035], ["churned_membership_invoice_amount", "total_downgrade_churn_amount", 0.39344262295081966], ["churned_membership_invoice_amount", "total_enrolled_users", 0.2641509433962264], ["churned_membership_invoice_amount", "total_enrollments", 0.2], ["churned_membership_invoice_amount", "total_estimated_cost", 0.18867924528301888], ["churned_membership_invoice_amount", "total_event_registrations_goal", 0.12698412698412698], ["churned_membership_invoice_amount", "total_events", 0.17777777777777778], ["churned_membership_invoice_amount", "total_first_time_contributors", 0.22580645161290322], ["churned_membership_invoice_amount", "total_gross_revenue", 0.07692307692307693], ["churned_membership_invoice_amount", "total_invoice_amount", 0.5660377358490566], ["churned_membership_invoice_amount", "total_maintainer_records", 0.14035087719298245], ["churned_membership_invoice_amount", "total_maintainers", 0.2], ["churned_membership_invoice_amount", "total_next_membership_revenue", 0.5161290322580645], ["churned_membership_invoice_amount", "total_registration_net_revenue", 0.25396825396825395], ["churned_membership_invoice_amount", "total_registration_tax", 0.14545454545454545], ["churned_membership_invoice_amount", "total_registrations", 0.11538461538461539], ["churned_membership_invoice_amount", "total_software_value", 0.18867924528301888], ["churned_membership_invoice_amount", "total_speakers", 0.1702127659574468], ["churned_membership_invoice_amount", "total_speaking_engagements", 0.2033898305084746], ["churned_membership_invoice_amount", "total_sponsorship_count", 0.42857142857142855], ["churned_membership_invoice_amount", "total_sponsorship_revenue", 0.3103448275862069], ["churned_membership_invoice_amount", "training_enrollments", 0.2641509433962264], ["churned_membership_invoice_amount", "upcoming_events_count", 0.37037037037037035], ["code_contribution_activities", "active_maintainer_records", 0.37735849056603776], ["code_contribution_activities", "active_maintainers", 0.43478260869565216], ["code_contribution_activities", "approved_pull_requests", 0.24], ["code_contribution_activities", "avg_project_health_score", 0.11538461538461539], ["code_contribution_activities", "bot_activities", 0.6190476190476191], ["code_contribution_activities", "certification_enrollments", 0.37735849056603776], ["code_contribution_activities", "churned_membership_count", 0.3076923076923077], ["code_contribution_activities", "churned_membership_discount_amount", 0.22580645161290322], ["code_contribution_activities", "churned_membership_invoice_amount", 0.22950819672131148], ["code_contribution_activities", "code_contribution_activities", 1.0], ["code_contribution_activities", "current_membership_count", 0.2692307692307692], ["code_contribution_activities", "current_membership_discount_amount", 0.22580645161290322], ["code_contribution_activities", "current_membership_invoice_amount", 0.22950819672131148], ["code_contribution_activities", "current_membership_revenue", 0.2962962962962963], ["code_contribution_activities", "current_new_account_membership_count", 0.25], ["code_contribution_activities", "human_activities", 0.5909090909090909], ["code_contribution_activities", "last_completed_year_active_discount_amount", 0.37142857142857144], ["code_contribution_activities", "last_completed_year_active_invoice_amount", 0.37681159420289856], ["code_contribution_activities", "last_completed_year_active_membership_count", 0.36619718309859156], ["code_contribution_activities", "last_completed_year_active_membership_revenue", 0.3561643835616438], ["code_contribution_activities", "lf_project_activities", 0.5306122448979592], ["code_contribution_activities", "main_branch_commits", 0.2978723404255319], ["code_contribution_activities", "membership_revenue", 0.21739130434782608], ["code_contribution_activities", "past_event_speakers", 0.2127659574468085], ["code_contribution_activities", "past_events_count", 0.26666666666666666], ["code_contribution_activities", "project_count", 0.2926829268292683], ["code_contribution_activities", "project_health_count", 0.2916666666666667], ["code_contribution_activities", "renewal_price", 0.2926829268292683], ["code_contribution_activities", "sponsorship_quantity_total", 0.3333333333333333], ["code_contribution_activities", "total_accepted_proposals", 0.3076923076923077], ["code_contribution_activities", "total_activities", 0.5909090909090909], ["code_contribution_activities", "total_certifications", 0.4166666666666667], ["code_contribution_activities", "total_code_deletions", 0.4166666666666667], ["code_contribution_activities", "total_code_insertions", 0.4897959183673469], ["code_contribution_activities", "total_contributing_organizations", 0.5], ["code_contribution_activities", "total_contributors", 0.5652173913043478], ["code_contribution_activities", "total_discount_amount", 0.2857142857142857], ["code_contribution_activities", "total_downgrade_churn_amount", 0.35714285714285715], ["code_contribution_activities", "total_enrolled_users", 0.20833333333333334], ["code_contribution_activities", "total_enrollments", 0.26666666666666666], ["code_contribution_activities", "total_estimated_cost", 0.25], ["code_contribution_activities", "total_event_registrations_goal", 0.41379310344827586], ["code_contribution_activities", "total_events", 0.25], ["code_contribution_activities", "total_first_time_contributors", 0.49122807017543857], ["code_contribution_activities", "total_gross_revenue", 0.2127659574468085], ["code_contribution_activities", "total_invoice_amount", 0.25], ["code_contribution_activities", "total_maintainer_records", 0.15384615384615385], ["code_contribution_activities", "total_maintainers", 0.26666666666666666], ["code_contribution_activities", "total_next_membership_revenue", 0.21052631578947367], ["code_contribution_activities", "total_registration_net_revenue", 0.41379310344827586], ["code_contribution_activities", "total_registration_tax", 0.4], ["code_contribution_activities", "total_registrations", 0.3829787234042553], ["code_contribution_activities", "total_software_value", 0.20833333333333334], ["code_contribution_activities", "total_speakers", 0.19047619047619047], ["code_contribution_activities", "total_speaking_engagements", 0.2222222222222222], ["code_contribution_activities", "total_sponsorship_count", 0.23529411764705882], ["code_contribution_activities", "total_sponsorship_revenue", 0.3018867924528302], ["code_contribution_activities", "training_enrollments", 0.20833333333333334], ["code_contribution_activities", "upcoming_events_count", 0.32653061224489793], ["current_membership_count", "active_maintainer_records", 0.24489795918367346], ["current_membership_count", "active_maintainers", 0.3333333333333333], ["current_membership_count", "approved_pull_requests", 0.21739130434782608], ["current_membership_count", "avg_project_health_score", 0.125], ["current_membership_count", "bot_activities", 0.21052631578947367], ["current_membership_count", "certification_enrollments", 0.2857142857142857], ["current_membership_count", "churned_membership_count", 0.875], ["current_membership_count", "churned_membership_discount_amount", 0.7241379310344828], ["current_membership_count", "churned_membership_invoice_amount", 0.7368421052631579], ["current_membership_count", "code_contribution_activities", 0.2692307692307692], ["current_membership_count", "current_membership_count", 1.0], ["current_membership_count", "current_membership_discount_amount", 0.8275862068965517], ["current_membership_count", "current_membership_invoice_amount", 0.8421052631578947], ["current_membership_count", "current_membership_revenue", 0.8], ["current_membership_count", "current_new_account_membership_count", 0.8], ["current_membership_count", "human_activities", 0.15], ["current_membership_count", "last_completed_year_active_discount_amount", 0.36363636363636365], ["current_membership_count", "last_completed_year_active_invoice_amount", 0.27692307692307694], ["current_membership_count", "last_completed_year_active_membership_count", 0.5970149253731343], ["current_membership_count", "last_completed_year_active_membership_revenue", 0.463768115942029], ["current_membership_count", "lf_project_activities", 0.2222222222222222], ["current_membership_count", "main_branch_commits", 0.27906976744186046], ["current_membership_count", "membership_revenue", 0.5714285714285714], ["current_membership_count", "past_event_speakers", 0.37209302325581395], ["current_membership_count", "past_events_count", 0.4878048780487805], ["current_membership_count", "project_count", 0.43243243243243246], ["current_membership_count", "project_health_count", 0.5], ["current_membership_count", "renewal_price", 0.2702702702702703], ["current_membership_count", "sponsorship_quantity_total", 0.4], ["current_membership_count", "total_accepted_proposals", 0.125], ["current_membership_count", "total_activities", 0.15], ["current_membership_count", "total_certifications", 0.18181818181818182], ["current_membership_count", "total_code_deletions", 0.22727272727272727], ["current_membership_count", "total_code_insertions", 0.26666666666666666], ["current_membership_count", "total_contributing_organizations", 0.21428571428571427], ["current_membership_count", "total_contributors", 0.2857142857142857], ["current_membership_count", "total_discount_amount", 0.35555555555555557], ["current_membership_count", "total_downgrade_churn_amount", 0.38461538461538464], ["current_membership_count", "total_enrolled_users", 0.2727272727272727], ["current_membership_count", "total_enrollments", 0.24390243902439024], ["current_membership_count", "total_estimated_cost", 0.36363636363636365], ["current_membership_count", "total_event_registrations_goal", 0.3333333333333333], ["current_membership_count", "total_events", 0.2222222222222222], ["current_membership_count", "total_first_time_contributors", 0.37735849056603776], ["current_membership_count", "total_gross_revenue", 0.23255813953488372], ["current_membership_count", "total_invoice_amount", 0.36363636363636365], ["current_membership_count", "total_maintainer_records", 0.20833333333333334], ["current_membership_count", "total_maintainers", 0.24390243902439024], ["current_membership_count", "total_next_membership_revenue", 0.5660377358490566], ["current_membership_count", "total_registration_net_revenue", 0.2962962962962963], ["current_membership_count", "total_registration_tax", 0.17391304347826086], ["current_membership_count", "total_registrations", 0.18604651162790697], ["current_membership_count", "total_software_value", 0.18181818181818182], ["current_membership_count", "total_speakers", 0.21052631578947367], ["current_membership_count", "total_speaking_engagements", 0.16], ["current_membership_count", "total_sponsorship_count", 0.5106382978723404], ["current_membership_count", "total_sponsorship_revenue", 0.32653061224489793], ["current_membership_count", "training_enrollments", 0.2727272727272727], ["current_membership_count", "upcoming_events_count", 0.4888888888888889], ["current_membership_discount_amount", "active_maintainer_records", 0.2033898305084746], ["current_membership_discount_amount", "active_maintainers", 0.2692307692307692], ["current_membership_discount_amount", "approved_pull_requests", 0.17857142857142858], ["current_membership_discount_amount", "avg_project_health_score", 0.3103448275862069], ["current_membership_discount_amount", "bot_activities", 0.25], ["current_membership_discount_amount", "certification_enrollments", 0.23728813559322035], ["current_membership_discount_amount", "churned_membership_count", 0.7241379310344828], ["current_membership_discount_amount", "churned_membership_discount_amount", 0.9117647058823529], ["current_membership_discount_amount", "churned_membership_invoice_amount", 0.746268656716418], ["current_membership_discount_amount", "code_contribution_activities", 0.3548387096774194], ["current_membership_discount_amount", "current_membership_count", 0.8275862068965517], ["current_membership_discount_amount", "current_membership_discount_amount", 1.0], ["current_membership_discount_amount", "current_membership_invoice_amount", 0.835820895522388], ["current_membership_discount_amount", "current_membership_revenue", 0.6666666666666666], ["current_membership_discount_amount", "current_new_account_membership_count", 0.6857142857142857], ["current_membership_discount_amount", "human_activities", 0.2], ["current_membership_discount_amount", "last_completed_year_active_discount_amount", 0.6052631578947368], ["current_membership_discount_amount", "last_completed_year_active_invoice_amount", 0.32], ["current_membership_discount_amount", "last_completed_year_active_membership_count", 0.5194805194805194], ["current_membership_discount_amount", "last_completed_year_active_membership_revenue", 0.4050632911392405], ["current_membership_discount_amount", "lf_project_activities", 0.18181818181818182], ["current_membership_discount_amount", "main_branch_commits", 0.18867924528301888], ["current_membership_discount_amount", "membership_revenue", 0.46153846153846156], ["current_membership_discount_amount", "past_event_speakers", 0.3018867924528302], ["current_membership_discount_amount", "past_events_count", 0.35294117647058826], ["current_membership_discount_amount", "project_count", 0.3404255319148936], ["current_membership_discount_amount", "project_health_count", 0.4074074074074074], ["current_membership_discount_amount", "renewal_price", 0.2127659574468085], ["current_membership_discount_amount", "sponsorship_quantity_total", 0.4], ["current_membership_discount_amount", "total_accepted_proposals", 0.20689655172413793], ["current_membership_discount_amount", "total_activities", 0.24], ["current_membership_discount_amount", "total_certifications", 0.14814814814814814], ["current_membership_discount_amount", "total_code_deletions", 0.2222222222222222], ["current_membership_discount_amount", "total_code_insertions", 0.21818181818181817], ["current_membership_discount_amount", "total_contributing_organizations", 0.18181818181818182], ["current_membership_discount_amount", "total_contributors", 0.23076923076923078], ["current_membership_discount_amount", "total_discount_amount", 0.6181818181818182], ["current_membership_discount_amount", "total_downgrade_churn_amount", 0.3548387096774194], ["current_membership_discount_amount", "total_enrolled_users", 0.2222222222222222], ["current_membership_discount_amount", "total_enrollments", 0.19607843137254902], ["current_membership_discount_amount", "total_estimated_cost", 0.2222222222222222], ["current_membership_discount_amount", "total_event_registrations_goal", 0.34375], ["current_membership_discount_amount", "total_events", 0.17391304347826086], ["current_membership_discount_amount", "total_first_time_contributors", 0.19047619047619047], ["current_membership_discount_amount", "total_gross_revenue", 0.18867924528301888], ["current_membership_discount_amount", "total_invoice_amount", 0.3333333333333333], ["current_membership_discount_amount", "total_maintainer_records", 0.1724137931034483], ["current_membership_discount_amount", "total_maintainers", 0.19607843137254902], ["current_membership_discount_amount", "total_next_membership_revenue", 0.47619047619047616], ["current_membership_discount_amount", "total_registration_net_revenue", 0.25], ["current_membership_discount_amount", "total_registration_tax", 0.2857142857142857], ["current_membership_discount_amount", "total_registrations", 0.22641509433962265], ["current_membership_discount_amount", "total_software_value", 0.14814814814814814], ["current_membership_discount_amount", "total_speakers", 0.16666666666666666], ["current_membership_discount_amount", "total_speaking_engagements", 0.13333333333333333], ["current_membership_discount_amount", "total_sponsorship_count", 0.42105263157894735], ["current_membership_discount_amount", "total_sponsorship_revenue", 0.2711864406779661], ["current_membership_discount_amount", "training_enrollments", 0.2222222222222222], ["current_membership_discount_amount", "upcoming_events_count", 0.36363636363636365], ["current_membership_invoice_amount", "active_maintainer_records", 0.20689655172413793], ["current_membership_invoice_amount", "active_maintainers", 0.27450980392156865], ["current_membership_invoice_amount", "approved_pull_requests", 0.18181818181818182], ["current_membership_invoice_amount", "avg_project_health_score", 0.10526315789473684], ["current_membership_invoice_amount", "bot_activities", 0.1702127659574468], ["current_membership_invoice_amount", "certification_enrollments", 0.2413793103448276], ["current_membership_invoice_amount", "churned_membership_count", 0.7368421052631579], ["current_membership_invoice_amount", "churned_membership_discount_amount", 0.746268656716418], ["current_membership_invoice_amount", "churned_membership_invoice_amount", 0.9090909090909091], ["current_membership_invoice_amount", "code_contribution_activities", 0.32786885245901637], ["current_membership_invoice_amount", "current_membership_count", 0.8421052631578947], ["current_membership_invoice_amount", "current_membership_discount_amount", 0.835820895522388], ["current_membership_invoice_amount", "current_membership_invoice_amount", 1.0], ["current_membership_invoice_amount", "current_membership_revenue", 0.711864406779661], ["current_membership_invoice_amount", "current_new_account_membership_count", 0.6956521739130435], ["current_membership_invoice_amount", "human_activities", 0.20408163265306123], ["current_membership_invoice_amount", "last_completed_year_active_discount_amount", 0.29333333333333333], ["current_membership_invoice_amount", "last_completed_year_active_invoice_amount", 0.5945945945945946], ["current_membership_invoice_amount", "last_completed_year_active_membership_count", 0.5263157894736842], ["current_membership_invoice_amount", "last_completed_year_active_membership_revenue", 0.4358974358974359], ["current_membership_invoice_amount", "lf_project_activities", 0.18518518518518517], ["current_membership_invoice_amount", "main_branch_commits", 0.23076923076923078], ["current_membership_invoice_amount", "membership_revenue", 0.5098039215686274], ["current_membership_invoice_amount", "past_event_speakers", 0.3076923076923077], ["current_membership_invoice_amount", "past_events_count", 0.36], ["current_membership_invoice_amount", "project_count", 0.34782608695652173], ["current_membership_invoice_amount", "project_health_count", 0.41509433962264153], ["current_membership_invoice_amount", "renewal_price", 0.34782608695652173], ["current_membership_invoice_amount", "sponsorship_quantity_total", 0.3389830508474576], ["current_membership_invoice_amount", "total_accepted_proposals", 0.17543859649122806], ["current_membership_invoice_amount", "total_activities", 0.20408163265306123], ["current_membership_invoice_amount", "total_certifications", 0.33962264150943394], ["current_membership_invoice_amount", "total_code_deletions", 0.22641509433962265], ["current_membership_invoice_amount", "total_code_insertions", 0.25925925925925924], ["current_membership_invoice_amount", "total_contributing_organizations", 0.3384615384615385], ["current_membership_invoice_amount", "total_contributors", 0.23529411764705882], ["current_membership_invoice_amount", "total_discount_amount", 0.4074074074074074], ["current_membership_invoice_amount", "total_downgrade_churn_amount", 0.36065573770491804], ["current_membership_invoice_amount", "total_enrolled_users", 0.22641509433962265], ["current_membership_invoice_amount", "total_enrollments", 0.2], ["current_membership_invoice_amount", "total_estimated_cost", 0.07547169811320754], ["current_membership_invoice_amount", "total_event_registrations_goal", 0.31746031746031744], ["current_membership_invoice_amount", "total_events", 0.17777777777777778], ["current_membership_invoice_amount", "total_first_time_contributors", 0.1935483870967742], ["current_membership_invoice_amount", "total_gross_revenue", 0.19230769230769232], ["current_membership_invoice_amount", "total_invoice_amount", 0.6037735849056604], ["current_membership_invoice_amount", "total_maintainer_records", 0.17543859649122806], ["current_membership_invoice_amount", "total_maintainers", 0.2], ["current_membership_invoice_amount", "total_next_membership_revenue", 0.5161290322580645], ["current_membership_invoice_amount", "total_registration_net_revenue", 0.2857142857142857], ["current_membership_invoice_amount", "total_registration_tax", 0.18181818181818182], ["current_membership_invoice_amount", "total_registrations", 0.15384615384615385], ["current_membership_invoice_amount", "total_software_value", 0.1509433962264151], ["current_membership_invoice_amount", "total_speakers", 0.1702127659574468], ["current_membership_invoice_amount", "total_speaking_engagements", 0.13559322033898305], ["current_membership_invoice_amount", "total_sponsorship_count", 0.42857142857142855], ["current_membership_invoice_amount", "total_sponsorship_revenue", 0.3103448275862069], ["current_membership_invoice_amount", "training_enrollments", 0.22641509433962265], ["current_membership_invoice_amount", "upcoming_events_count", 0.37037037037037035], ["current_membership_revenue", "active_maintainer_records", 0.35294117647058826], ["current_membership_revenue", "active_maintainers", 0.3181818181818182], ["current_membership_revenue", "approved_pull_requests", 0.25], ["current_membership_revenue", "avg_project_health_score", 0.12], ["current_membership_revenue", "bot_activities", 0.2], ["current_membership_revenue", "certification_enrollments", 0.27450980392156865], ["current_membership_revenue", "churned_membership_count", 0.68], ["current_membership_revenue", "churned_membership_discount_amount", 0.6], ["current_membership_revenue", "churned_membership_invoice_amount", 0.6101694915254238], ["current_membership_revenue", "code_contribution_activities", 0.25925925925925924], ["current_membership_revenue", "current_membership_count", 0.8], ["current_membership_revenue", "current_membership_discount_amount", 0.7], ["current_membership_revenue", "current_membership_invoice_amount", 0.711864406779661], ["current_membership_revenue", "current_membership_revenue", 1.0], ["current_membership_revenue", "current_new_account_membership_count", 0.6451612903225806], ["current_membership_revenue", "human_activities", 0.14285714285714285], ["current_membership_revenue", "last_completed_year_active_discount_amount", 0.20588235294117646], ["current_membership_revenue", "last_completed_year_active_invoice_amount", 0.11940298507462686], ["current_membership_revenue", "last_completed_year_active_membership_count", 0.463768115942029], ["current_membership_revenue", "last_completed_year_active_membership_revenue", 0.6197183098591549], ["current_membership_revenue", "lf_project_activities", 0.2127659574468085], ["current_membership_revenue", "main_branch_commits", 0.13333333333333333], ["current_membership_revenue", "membership_revenue", 0.8181818181818182], ["current_membership_revenue", "past_event_speakers", 0.35555555555555557], ["current_membership_revenue", "past_events_count", 0.32558139534883723], ["current_membership_revenue", "project_count", 0.20512820512820512], ["current_membership_revenue", "project_health_count", 0.17391304347826086], ["current_membership_revenue", "renewal_price", 0.2564102564102564], ["current_membership_revenue", "sponsorship_quantity_total", 0.3076923076923077], ["current_membership_revenue", "total_accepted_proposals", 0.12], ["current_membership_revenue", "total_activities", 0.14285714285714285], ["current_membership_revenue", "total_certifications", 0.17391304347826086], ["current_membership_revenue", "total_code_deletions", 0.17391304347826086], ["current_membership_revenue", "total_code_insertions", 0.2553191489361702], ["current_membership_revenue", "total_contributing_organizations", 0.20689655172413793], ["current_membership_revenue", "total_contributors", 0.2727272727272727], ["current_membership_revenue", "total_discount_amount", 0.2978723404255319], ["current_membership_revenue", "total_downgrade_churn_amount", 0.18518518518518517], ["current_membership_revenue", "total_enrolled_users", 0.2608695652173913], ["current_membership_revenue", "total_enrollments", 0.23255813953488372], ["current_membership_revenue", "total_estimated_cost", 0.08695652173913043], ["current_membership_revenue", "total_event_registrations_goal", 0.25], ["current_membership_revenue", "total_events", 0.3157894736842105], ["current_membership_revenue", "total_first_time_contributors", 0.21818181818181817], ["current_membership_revenue", "total_gross_revenue", 0.4444444444444444], ["current_membership_revenue", "total_invoice_amount", 0.17391304347826086], ["current_membership_revenue", "total_maintainer_records", 0.28], ["current_membership_revenue", "total_maintainers", 0.23255813953488372], ["current_membership_revenue", "total_next_membership_revenue", 0.7636363636363637], ["current_membership_revenue", "total_registration_net_revenue", 0.42857142857142855], ["current_membership_revenue", "total_registration_tax", 0.20833333333333334], ["current_membership_revenue", "total_registrations", 0.2222222222222222], ["current_membership_revenue", "total_software_value", 0.2608695652173913], ["current_membership_revenue", "total_speakers", 0.2], ["current_membership_revenue", "total_speaking_engagements", 0.15384615384615385], ["current_membership_revenue", "total_sponsorship_count", 0.32653061224489793], ["current_membership_revenue", "total_sponsorship_revenue", 0.5490196078431373], ["current_membership_revenue", "training_enrollments", 0.2608695652173913], ["current_membership_revenue", "upcoming_events_count", 0.3404255319148936], ["current_new_account_membership_count", "active_maintainer_records", 0.26229508196721313], ["current_new_account_membership_count", "active_maintainers", 0.2962962962962963], ["current_new_account_membership_count", "approved_pull_requests", 0.1724137931034483], ["current_new_account_membership_count", "avg_project_health_score", 0.1], ["current_new_account_membership_count", "bot_activities", 0.28], ["current_new_account_membership_count", "certification_enrollments", 0.22950819672131148], ["current_new_account_membership_count", "churned_membership_count", 0.7333333333333333], ["current_new_account_membership_count", "churned_membership_discount_amount", 0.6285714285714286], ["current_new_account_membership_count", "churned_membership_invoice_amount", 0.6376811594202898], ["current_new_account_membership_count", "code_contribution_activities", 0.34375], ["current_new_account_membership_count", "current_membership_count", 0.8], ["current_new_account_membership_count", "current_membership_discount_amount", 0.6857142857142857], ["current_new_account_membership_count", "current_membership_invoice_amount", 0.6956521739130435], ["current_new_account_membership_count", "current_membership_revenue", 0.6451612903225806], ["current_new_account_membership_count", "current_new_account_membership_count", 1.0], ["current_new_account_membership_count", "human_activities", 0.3076923076923077], ["current_new_account_membership_count", "last_completed_year_active_discount_amount", 0.4358974358974359], ["current_new_account_membership_count", "last_completed_year_active_invoice_amount", 0.2857142857142857], ["current_new_account_membership_count", "last_completed_year_active_membership_count", 0.6075949367088608], ["current_new_account_membership_count", "last_completed_year_active_membership_revenue", 0.49382716049382713], ["current_new_account_membership_count", "lf_project_activities", 0.2807017543859649], ["current_new_account_membership_count", "main_branch_commits", 0.21818181818181817], ["current_new_account_membership_count", "membership_revenue", 0.4444444444444444], ["current_new_account_membership_count", "past_event_speakers", 0.32727272727272727], ["current_new_account_membership_count", "past_events_count", 0.37735849056603776], ["current_new_account_membership_count", "project_count", 0.32653061224489793], ["current_new_account_membership_count", "project_health_count", 0.4642857142857143], ["current_new_account_membership_count", "renewal_price", 0.32653061224489793], ["current_new_account_membership_count", "sponsorship_quantity_total", 0.3548387096774194], ["current_new_account_membership_count", "total_accepted_proposals", 0.23333333333333334], ["current_new_account_membership_count", "total_activities", 0.2692307692307692], ["current_new_account_membership_count", "total_certifications", 0.14285714285714285], ["current_new_account_membership_count", "total_code_deletions", 0.21428571428571427], ["current_new_account_membership_count", "total_code_insertions", 0.24561403508771928], ["current_new_account_membership_count", "total_contributing_organizations", 0.20588235294117646], ["current_new_account_membership_count", "total_contributors", 0.25925925925925924], ["current_new_account_membership_count", "total_discount_amount", 0.45614035087719296], ["current_new_account_membership_count", "total_downgrade_churn_amount", 0.3125], ["current_new_account_membership_count", "total_enrolled_users", 0.25], ["current_new_account_membership_count", "total_enrollments", 0.18867924528301888], ["current_new_account_membership_count", "total_estimated_cost", 0.25], ["current_new_account_membership_count", "total_event_registrations_goal", 0.21212121212121213], ["current_new_account_membership_count", "total_events", 0.16666666666666666], ["current_new_account_membership_count", "total_first_time_contributors", 0.3384615384615385], ["current_new_account_membership_count", "total_gross_revenue", 0.18181818181818182], ["current_new_account_membership_count", "total_invoice_amount", 0.2857142857142857], ["current_new_account_membership_count", "total_maintainer_records", 0.23333333333333334], ["current_new_account_membership_count", "total_maintainers", 0.22641509433962265], ["current_new_account_membership_count", "total_next_membership_revenue", 0.5538461538461539], ["current_new_account_membership_count", "total_registration_net_revenue", 0.3333333333333333], ["current_new_account_membership_count", "total_registration_tax", 0.1724137931034483], ["current_new_account_membership_count", "total_registrations", 0.14545454545454545], ["current_new_account_membership_count", "total_software_value", 0.14285714285714285], ["current_new_account_membership_count", "total_speakers", 0.2], ["current_new_account_membership_count", "total_speaking_engagements", 0.12903225806451613], ["current_new_account_membership_count", "total_sponsorship_count", 0.4406779661016949], ["current_new_account_membership_count", "total_sponsorship_revenue", 0.29508196721311475], ["current_new_account_membership_count", "training_enrollments", 0.21428571428571427], ["current_new_account_membership_count", "upcoming_events_count", 0.38596491228070173], ["human_activities", "active_maintainer_records", 0.4878048780487805], ["human_activities", "active_maintainers", 0.5882352941176471], ["human_activities", "approved_pull_requests", 0.21052631578947367], ["human_activities", "avg_project_health_score", 0.3], ["human_activities", "bot_activities", 0.7333333333333333], ["human_activities", "certification_enrollments", 0.24390243902439024], ["human_activities", "churned_membership_count", 0.25], ["human_activities", "churned_membership_discount_amount", 0.28], ["human_activities", "churned_membership_invoice_amount", 0.2857142857142857], ["human_activities", "code_contribution_activities", 0.5909090909090909], ["human_activities", "current_membership_count", 0.2], ["human_activities", "current_membership_discount_amount", 0.24], ["human_activities", "current_membership_invoice_amount", 0.20408163265306123], ["human_activities", "current_membership_revenue", 0.14285714285714285], ["human_activities", "current_new_account_membership_count", 0.3076923076923077], ["human_activities", "human_activities", 1.0], ["human_activities", "last_completed_year_active_discount_amount", 0.3448275862068966], ["human_activities", "last_completed_year_active_invoice_amount", 0.3508771929824561], ["human_activities", "last_completed_year_active_membership_count", 0.3389830508474576], ["human_activities", "last_completed_year_active_membership_revenue", 0.32786885245901637], ["human_activities", "lf_project_activities", 0.5945945945945946], ["human_activities", "main_branch_commits", 0.5142857142857142], ["human_activities", "membership_revenue", 0.17647058823529413], ["human_activities", "past_event_speakers", 0.34285714285714286], ["human_activities", "past_events_count", 0.18181818181818182], ["human_activities", "project_count", 0.20689655172413793], ["human_activities", "project_health_count", 0.16666666666666666], ["human_activities", "renewal_price", 0.27586206896551724], ["human_activities", "sponsorship_quantity_total", 0.3333333333333333], ["human_activities", "total_accepted_proposals", 0.35], ["human_activities", "total_activities", 0.75], ["human_activities", "total_certifications", 0.5], ["human_activities", "total_code_deletions", 0.3333333333333333], ["human_activities", "total_code_insertions", 0.2702702702702703], ["human_activities", "total_contributing_organizations", 0.2916666666666667], ["human_activities", "total_contributors", 0.17647058823529413], ["human_activities", "total_discount_amount", 0.2702702702702703], ["human_activities", "total_downgrade_churn_amount", 0.2727272727272727], ["human_activities", "total_enrolled_users", 0.16666666666666666], ["human_activities", "total_enrollments", 0.24242424242424243], ["human_activities", "total_estimated_cost", 0.2777777777777778], ["human_activities", "total_event_registrations_goal", 0.30434782608695654], ["human_activities", "total_events", 0.2857142857142857], ["human_activities", "total_first_time_contributors", 0.3111111111111111], ["human_activities", "total_gross_revenue", 0.11428571428571428], ["human_activities", "total_invoice_amount", 0.2777777777777778], ["human_activities", "total_maintainer_records", 0.3], ["human_activities", "total_maintainers", 0.42424242424242425], ["human_activities", "total_next_membership_revenue", 0.13333333333333333], ["human_activities", "total_registration_net_revenue", 0.2608695652173913], ["human_activities", "total_registration_tax", 0.21052631578947367], ["human_activities", "total_registrations", 0.34285714285714286], ["human_activities", "total_software_value", 0.1111111111111111], ["human_activities", "total_speakers", 0.3333333333333333], ["human_activities", "total_speaking_engagements", 0.19047619047619047], ["human_activities", "total_sponsorship_count", 0.20512820512820512], ["human_activities", "total_sponsorship_revenue", 0.14634146341463414], ["human_activities", "training_enrollments", 0.2222222222222222], ["human_activities", "upcoming_events_count", 0.32432432432432434], ["last_completed_year_active_discount_amount", "active_maintainer_records", 0.29850746268656714], ["last_completed_year_active_discount_amount", "active_maintainers", 0.4], ["last_completed_year_active_discount_amount", "approved_pull_requests", 0.25], ["last_completed_year_active_discount_amount", "avg_project_health_score", 0.2727272727272727], ["last_completed_year_active_discount_amount", "bot_activities", 0.32142857142857145], ["last_completed_year_active_discount_amount", "certification_enrollments", 0.26865671641791045], ["last_completed_year_active_discount_amount", "churned_membership_count", 0.36363636363636365], ["last_completed_year_active_discount_amount", "churned_membership_discount_amount", 0.6052631578947368], ["last_completed_year_active_discount_amount", "churned_membership_invoice_amount", 0.4266666666666667], ["last_completed_year_active_discount_amount", "code_contribution_activities", 0.37142857142857144], ["last_completed_year_active_discount_amount", "current_membership_count", 0.30303030303030304], ["last_completed_year_active_discount_amount", "current_membership_discount_amount", 0.5263157894736842], ["last_completed_year_active_discount_amount", "current_membership_invoice_amount", 0.29333333333333333], ["last_completed_year_active_discount_amount", "current_membership_revenue", 0.20588235294117646], ["last_completed_year_active_discount_amount", "current_new_account_membership_count", 0.4358974358974359], ["last_completed_year_active_discount_amount", "human_activities", 0.3103448275862069], ["last_completed_year_active_discount_amount", "last_completed_year_active_discount_amount", 1.0], ["last_completed_year_active_discount_amount", "last_completed_year_active_invoice_amount", 0.8674698795180723], ["last_completed_year_active_discount_amount", "last_completed_year_active_membership_count", 0.7764705882352941], ["last_completed_year_active_discount_amount", "last_completed_year_active_membership_revenue", 0.6666666666666666], ["last_completed_year_active_discount_amount", "lf_project_activities", 0.31746031746031744], ["last_completed_year_active_discount_amount", "main_branch_commits", 0.22950819672131148], ["last_completed_year_active_discount_amount", "membership_revenue", 0.2], ["last_completed_year_active_discount_amount", "past_event_speakers", 0.36065573770491804], ["last_completed_year_active_discount_amount", "past_events_count", 0.4406779661016949], ["last_completed_year_active_discount_amount", "project_count", 0.2545454545454545], ["last_completed_year_active_discount_amount", "project_health_count", 0.3225806451612903], ["last_completed_year_active_discount_amount", "renewal_price", 0.14545454545454545], ["last_completed_year_active_discount_amount", "sponsorship_quantity_total", 0.20588235294117646], ["last_completed_year_active_discount_amount", "total_accepted_proposals", 0.30303030303030304], ["last_completed_year_active_discount_amount", "total_activities", 0.3103448275862069], ["last_completed_year_active_discount_amount", "total_certifications", 0.2903225806451613], ["last_completed_year_active_discount_amount", "total_code_deletions", 0.2903225806451613], ["last_completed_year_active_discount_amount", "total_code_insertions", 0.31746031746031744], ["last_completed_year_active_discount_amount", "total_contributing_organizations", 0.2972972972972973], ["last_completed_year_active_discount_amount", "total_contributors", 0.23333333333333334], ["last_completed_year_active_discount_amount", "total_discount_amount", 0.5396825396825397], ["last_completed_year_active_discount_amount", "total_downgrade_churn_amount", 0.37142857142857144], ["last_completed_year_active_discount_amount", "total_enrolled_users", 0.3225806451612903], ["last_completed_year_active_discount_amount", "total_enrollments", 0.23728813559322035], ["last_completed_year_active_discount_amount", "total_estimated_cost", 0.3548387096774194], ["last_completed_year_active_discount_amount", "total_event_registrations_goal", 0.3055555555555556], ["last_completed_year_active_discount_amount", "total_events", 0.25925925925925924], ["last_completed_year_active_discount_amount", "total_first_time_contributors", 0.2535211267605634], ["last_completed_year_active_discount_amount", "total_gross_revenue", 0.22950819672131148], ["last_completed_year_active_discount_amount", "total_invoice_amount", 0.3548387096774194], ["last_completed_year_active_discount_amount", "total_maintainer_records", 0.24242424242424243], ["last_completed_year_active_discount_amount", "total_maintainers", 0.23728813559322035], ["last_completed_year_active_discount_amount", "total_next_membership_revenue", 0.2535211267605634], ["last_completed_year_active_discount_amount", "total_registration_net_revenue", 0.2777777777777778], ["last_completed_year_active_discount_amount", "total_registration_tax", 0.28125], ["last_completed_year_active_discount_amount", "total_registrations", 0.22950819672131148], ["last_completed_year_active_discount_amount", "total_software_value", 0.25806451612903225], ["last_completed_year_active_discount_amount", "total_speakers", 0.25], ["last_completed_year_active_discount_amount", "total_speaking_engagements", 0.29411764705882354], ["last_completed_year_active_discount_amount", "total_sponsorship_count", 0.24615384615384617], ["last_completed_year_active_discount_amount", "total_sponsorship_revenue", 0.208955223880597], ["last_completed_year_active_discount_amount", "training_enrollments", 0.16129032258064516], ["last_completed_year_active_discount_amount", "upcoming_events_count", 0.38095238095238093], ["last_completed_year_active_invoice_amount", "active_maintainer_records", 0.30303030303030304], ["last_completed_year_active_invoice_amount", "active_maintainers", 0.3728813559322034], ["last_completed_year_active_invoice_amount", "approved_pull_requests", 0.2222222222222222], ["last_completed_year_active_invoice_amount", "avg_project_health_score", 0.18461538461538463], ["last_completed_year_active_invoice_amount", "bot_activities", 0.2909090909090909], ["last_completed_year_active_invoice_amount", "certification_enrollments", 0.3333333333333333], ["last_completed_year_active_invoice_amount", "churned_membership_count", 0.36923076923076925], ["last_completed_year_active_invoice_amount", "churned_membership_discount_amount", 0.4266666666666667], ["last_completed_year_active_invoice_amount", "churned_membership_invoice_amount", 0.5945945945945946], ["last_completed_year_active_invoice_amount", "code_contribution_activities", 0.34782608695652173], ["last_completed_year_active_invoice_amount", "current_membership_count", 0.2153846153846154], ["last_completed_year_active_invoice_amount", "current_membership_discount_amount", 0.32], ["last_completed_year_active_invoice_amount", "current_membership_invoice_amount", 0.5135135135135135], ["last_completed_year_active_invoice_amount", "current_membership_revenue", 0.26865671641791045], ["last_completed_year_active_invoice_amount", "current_new_account_membership_count", 0.2857142857142857], ["last_completed_year_active_invoice_amount", "human_activities", 0.2807017543859649], ["last_completed_year_active_invoice_amount", "last_completed_year_active_discount_amount", 0.8674698795180723], ["last_completed_year_active_invoice_amount", "last_completed_year_active_invoice_amount", 1.0], ["last_completed_year_active_invoice_amount", "last_completed_year_active_membership_count", 0.7857142857142857], ["last_completed_year_active_invoice_amount", "last_completed_year_active_membership_revenue", 0.6976744186046512], ["last_completed_year_active_invoice_amount", "lf_project_activities", 0.2903225806451613], ["last_completed_year_active_invoice_amount", "main_branch_commits", 0.2], ["last_completed_year_active_invoice_amount", "membership_revenue", 0.23728813559322035], ["last_completed_year_active_invoice_amount", "past_event_speakers", 0.26666666666666666], ["last_completed_year_active_invoice_amount", "past_events_count", 0.4482758620689655], ["last_completed_year_active_invoice_amount", "project_count", 0.25925925925925924], ["last_completed_year_active_invoice_amount", "project_health_count", 0.32786885245901637], ["last_completed_year_active_invoice_amount", "renewal_price", 0.25925925925925924], ["last_completed_year_active_invoice_amount", "sponsorship_quantity_total", 0.1791044776119403], ["last_completed_year_active_invoice_amount", "total_accepted_proposals", 0.27692307692307694], ["last_completed_year_active_invoice_amount", "total_activities", 0.2807017543859649], ["last_completed_year_active_invoice_amount", "total_certifications", 0.39344262295081966], ["last_completed_year_active_invoice_amount", "total_code_deletions", 0.29508196721311475], ["last_completed_year_active_invoice_amount", "total_code_insertions", 0.3548387096774194], ["last_completed_year_active_invoice_amount", "total_contributing_organizations", 0.3013698630136986], ["last_completed_year_active_invoice_amount", "total_contributors", 0.2033898305084746], ["last_completed_year_active_invoice_amount", "total_discount_amount", 0.3870967741935484], ["last_completed_year_active_invoice_amount", "total_downgrade_churn_amount", 0.37681159420289856], ["last_completed_year_active_invoice_amount", "total_enrolled_users", 0.29508196721311475], ["last_completed_year_active_invoice_amount", "total_enrollments", 0.2413793103448276], ["last_completed_year_active_invoice_amount", "total_estimated_cost", 0.32786885245901637], ["last_completed_year_active_invoice_amount", "total_event_registrations_goal", 0.2535211267605634], ["last_completed_year_active_invoice_amount", "total_events", 0.2641509433962264], ["last_completed_year_active_invoice_amount", "total_first_time_contributors", 0.22857142857142856], ["last_completed_year_active_invoice_amount", "total_gross_revenue", 0.26666666666666666], ["last_completed_year_active_invoice_amount", "total_invoice_amount", 0.5245901639344263], ["last_completed_year_active_invoice_amount", "total_maintainer_records", 0.2153846153846154], ["last_completed_year_active_invoice_amount", "total_maintainers", 0.20689655172413793], ["last_completed_year_active_invoice_amount", "total_next_membership_revenue", 0.2857142857142857], ["last_completed_year_active_invoice_amount", "total_registration_net_revenue", 0.30985915492957744], ["last_completed_year_active_invoice_amount", "total_registration_tax", 0.25396825396825395], ["last_completed_year_active_invoice_amount", "total_registrations", 0.23333333333333334], ["last_completed_year_active_invoice_amount", "total_software_value", 0.29508196721311475], ["last_completed_year_active_invoice_amount", "total_speakers", 0.21818181818181817], ["last_completed_year_active_invoice_amount", "total_speaking_engagements", 0.3582089552238806], ["last_completed_year_active_invoice_amount", "total_sponsorship_count", 0.25], ["last_completed_year_active_invoice_amount", "total_sponsorship_revenue", 0.24242424242424243], ["last_completed_year_active_invoice_amount", "training_enrollments", 0.22950819672131148], ["last_completed_year_active_invoice_amount", "upcoming_events_count", 0.3870967741935484], ["last_completed_year_active_membership_count", "active_maintainer_records", 0.38235294117647056], ["last_completed_year_active_membership_count", "active_maintainers", 0.36065573770491804], ["last_completed_year_active_membership_count", "approved_pull_requests", 0.24615384615384617], ["last_completed_year_active_membership_count", "avg_project_health_score", 0.1791044776119403], ["last_completed_year_active_membership_count", "bot_activities", 0.3157894736842105], ["last_completed_year_active_membership_count", "certification_enrollments", 0.29411764705882354], ["last_completed_year_active_membership_count", "churned_membership_count", 0.5970149253731343], ["last_completed_year_active_membership_count", "churned_membership_discount_amount", 0.5194805194805194], ["last_completed_year_active_membership_count", "churned_membership_invoice_amount", 0.5263157894736842], ["last_completed_year_active_membership_count", "code_contribution_activities", 0.36619718309859156], ["last_completed_year_active_membership_count", "current_membership_count", 0.5373134328358209], ["last_completed_year_active_membership_count", "current_membership_discount_amount", 0.4675324675324675], ["last_completed_year_active_membership_count", "current_membership_invoice_amount", 0.47368421052631576], ["last_completed_year_active_membership_count", "current_membership_revenue", 0.4057971014492754], ["last_completed_year_active_membership_count", "current_new_account_membership_count", 0.6075949367088608], ["last_completed_year_active_membership_count", "human_activities", 0.3050847457627119], ["last_completed_year_active_membership_count", "last_completed_year_active_discount_amount", 0.7764705882352941], ["last_completed_year_active_membership_count", "last_completed_year_active_invoice_amount", 0.7619047619047619], ["last_completed_year_active_membership_count", "last_completed_year_active_membership_count", 1.0], ["last_completed_year_active_membership_count", "last_completed_year_active_membership_revenue", 0.8863636363636364], ["last_completed_year_active_membership_count", "lf_project_activities", 0.3125], ["last_completed_year_active_membership_count", "main_branch_commits", 0.22580645161290322], ["last_completed_year_active_membership_count", "membership_revenue", 0.39344262295081966], ["last_completed_year_active_membership_count", "past_event_speakers", 0.3225806451612903], ["last_completed_year_active_membership_count", "past_events_count", 0.4666666666666667], ["last_completed_year_active_membership_count", "project_count", 0.35714285714285715], ["last_completed_year_active_membership_count", "project_health_count", 0.38095238095238093], ["last_completed_year_active_membership_count", "renewal_price", 0.14285714285714285], ["last_completed_year_active_membership_count", "sponsorship_quantity_total", 0.3188405797101449], ["last_completed_year_active_membership_count", "total_accepted_proposals", 0.29850746268656714], ["last_completed_year_active_membership_count", "total_activities", 0.3050847457627119], ["last_completed_year_active_membership_count", "total_certifications", 0.25396825396825395], ["last_completed_year_active_membership_count", "total_code_deletions", 0.2857142857142857], ["last_completed_year_active_membership_count", "total_code_insertions", 0.3125], ["last_completed_year_active_membership_count", "total_contributing_organizations", 0.29333333333333333], ["last_completed_year_active_membership_count", "total_contributors", 0.29508196721311475], ["last_completed_year_active_membership_count", "total_discount_amount", 0.21875], ["last_completed_year_active_membership_count", "total_downgrade_churn_amount", 0.3380281690140845], ["last_completed_year_active_membership_count", "total_enrolled_users", 0.31746031746031744], ["last_completed_year_active_membership_count", "total_enrollments", 0.26666666666666666], ["last_completed_year_active_membership_count", "total_estimated_cost", 0.3492063492063492], ["last_completed_year_active_membership_count", "total_event_registrations_goal", 0.2191780821917808], ["last_completed_year_active_membership_count", "total_events", 0.2545454545454545], ["last_completed_year_active_membership_count", "total_first_time_contributors", 0.3611111111111111], ["last_completed_year_active_membership_count", "total_gross_revenue", 0.22580645161290322], ["last_completed_year_active_membership_count", "total_invoice_amount", 0.3492063492063492], ["last_completed_year_active_membership_count", "total_maintainer_records", 0.23880597014925373], ["last_completed_year_active_membership_count", "total_maintainers", 0.2], ["last_completed_year_active_membership_count", "total_next_membership_revenue", 0.4166666666666667], ["last_completed_year_active_membership_count", "total_registration_net_revenue", 0.273972602739726], ["last_completed_year_active_membership_count", "total_registration_tax", 0.24615384615384617], ["last_completed_year_active_membership_count", "total_registrations", 0.22580645161290322], ["last_completed_year_active_membership_count", "total_software_value", 0.25396825396825395], ["last_completed_year_active_membership_count", "total_speakers", 0.2807017543859649], ["last_completed_year_active_membership_count", "total_speaking_engagements", 0.34782608695652173], ["last_completed_year_active_membership_count", "total_sponsorship_count", 0.42424242424242425], ["last_completed_year_active_membership_count", "total_sponsorship_revenue", 0.29411764705882354], ["last_completed_year_active_membership_count", "training_enrollments", 0.19047619047619047], ["last_completed_year_active_membership_count", "upcoming_events_count", 0.40625], ["last_completed_year_active_membership_revenue", "active_maintainer_records", 0.37142857142857144], ["last_completed_year_active_membership_revenue", "active_maintainers", 0.3492063492063492], ["last_completed_year_active_membership_revenue", "approved_pull_requests", 0.3283582089552239], ["last_completed_year_active_membership_revenue", "avg_project_health_score", 0.2028985507246377], ["last_completed_year_active_membership_revenue", "bot_activities", 0.3050847457627119], ["last_completed_year_active_membership_revenue", "certification_enrollments", 0.2571428571428571], ["last_completed_year_active_membership_revenue", "churned_membership_count", 0.463768115942029], ["last_completed_year_active_membership_revenue", "churned_membership_discount_amount", 0.43037974683544306], ["last_completed_year_active_membership_revenue", "churned_membership_invoice_amount", 0.4358974358974359], ["last_completed_year_active_membership_revenue", "code_contribution_activities", 0.3561643835616438], ["last_completed_year_active_membership_revenue", "current_membership_count", 0.4057971014492754], ["last_completed_year_active_membership_revenue", "current_membership_discount_amount", 0.379746835443038], ["last_completed_year_active_membership_revenue", "current_membership_invoice_amount", 0.38461538461538464], ["last_completed_year_active_membership_revenue", "current_membership_revenue", 0.5633802816901409], ["last_completed_year_active_membership_revenue", "current_new_account_membership_count", 0.49382716049382713], ["last_completed_year_active_membership_revenue", "human_activities", 0.29508196721311475], ["last_completed_year_active_membership_revenue", "last_completed_year_active_discount_amount", 0.6666666666666666], ["last_completed_year_active_membership_revenue", "last_completed_year_active_invoice_amount", 0.6744186046511628], ["last_completed_year_active_membership_revenue", "last_completed_year_active_membership_count", 0.8863636363636364], ["last_completed_year_active_membership_revenue", "last_completed_year_active_membership_revenue", 1.0], ["last_completed_year_active_membership_revenue", "lf_project_activities", 0.30303030303030304], ["last_completed_year_active_membership_revenue", "main_branch_commits", 0.21875], ["last_completed_year_active_membership_revenue", "membership_revenue", 0.5714285714285714], ["last_completed_year_active_membership_revenue", "past_event_speakers", 0.28125], ["last_completed_year_active_membership_revenue", "past_events_count", 0.2903225806451613], ["last_completed_year_active_membership_revenue", "project_count", 0.1724137931034483], ["last_completed_year_active_membership_revenue", "project_health_count", 0.18461538461538463], ["last_completed_year_active_membership_revenue", "renewal_price", 0.13793103448275862], ["last_completed_year_active_membership_revenue", "sponsorship_quantity_total", 0.2535211267605634], ["last_completed_year_active_membership_revenue", "total_accepted_proposals", 0.2898550724637681], ["last_completed_year_active_membership_revenue", "total_activities", 0.29508196721311475], ["last_completed_year_active_membership_revenue", "total_certifications", 0.24615384615384617], ["last_completed_year_active_membership_revenue", "total_code_deletions", 0.27692307692307694], ["last_completed_year_active_membership_revenue", "total_code_insertions", 0.30303030303030304], ["last_completed_year_active_membership_revenue", "total_contributing_organizations", 0.2857142857142857], ["last_completed_year_active_membership_revenue", "total_contributors", 0.2857142857142857], ["last_completed_year_active_membership_revenue", "total_discount_amount", 0.18181818181818182], ["last_completed_year_active_membership_revenue", "total_downgrade_churn_amount", 0.2191780821917808], ["last_completed_year_active_membership_revenue", "total_enrolled_users", 0.3076923076923077], ["last_completed_year_active_membership_revenue", "total_enrollments", 0.22580645161290322], ["last_completed_year_active_membership_revenue", "total_estimated_cost", 0.3076923076923077], ["last_completed_year_active_membership_revenue", "total_event_registrations_goal", 0.18666666666666668], ["last_completed_year_active_membership_revenue", "total_events", 0.21052631578947367], ["last_completed_year_active_membership_revenue", "total_first_time_contributors", 0.2972972972972973], ["last_completed_year_active_membership_revenue", "total_gross_revenue", 0.34375], ["last_completed_year_active_membership_revenue", "total_invoice_amount", 0.2153846153846154], ["last_completed_year_active_membership_revenue", "total_maintainer_records", 0.2318840579710145], ["last_completed_year_active_membership_revenue", "total_maintainers", 0.1935483870967742], ["last_completed_year_active_membership_revenue", "total_next_membership_revenue", 0.5675675675675675], ["last_completed_year_active_membership_revenue", "total_registration_net_revenue", 0.37333333333333335], ["last_completed_year_active_membership_revenue", "total_registration_tax", 0.14925373134328357], ["last_completed_year_active_membership_revenue", "total_registrations", 0.15625], ["last_completed_year_active_membership_revenue", "total_software_value", 0.3076923076923077], ["last_completed_year_active_membership_revenue", "total_speakers", 0.2711864406779661], ["last_completed_year_active_membership_revenue", "total_speaking_engagements", 0.30985915492957744], ["last_completed_year_active_membership_revenue", "total_sponsorship_count", 0.29411764705882354], ["last_completed_year_active_membership_revenue", "total_sponsorship_revenue", 0.45714285714285713], ["last_completed_year_active_membership_revenue", "training_enrollments", 0.15384615384615385], ["last_completed_year_active_membership_revenue", "upcoming_events_count", 0.2727272727272727], ["lf_project_activities", "active_maintainer_records", 0.43478260869565216], ["lf_project_activities", "active_maintainers", 0.5128205128205128], ["lf_project_activities", "approved_pull_requests", 0.32558139534883723], ["lf_project_activities", "avg_project_health_score", 0.5333333333333333], ["lf_project_activities", "bot_activities", 0.7428571428571429], ["lf_project_activities", "certification_enrollments", 0.30434782608695654], ["lf_project_activities", "churned_membership_count", 0.17777777777777778], ["lf_project_activities", "churned_membership_discount_amount", 0.2545454545454545], ["lf_project_activities", "churned_membership_invoice_amount", 0.25925925925925924], ["lf_project_activities", "code_contribution_activities", 0.5714285714285714], ["lf_project_activities", "current_membership_count", 0.26666666666666666], ["lf_project_activities", "current_membership_discount_amount", 0.2545454545454545], ["lf_project_activities", "current_membership_invoice_amount", 0.2222222222222222], ["lf_project_activities", "current_membership_revenue", 0.2978723404255319], ["lf_project_activities", "current_new_account_membership_count", 0.2807017543859649], ["lf_project_activities", "human_activities", 0.5945945945945946], ["lf_project_activities", "last_completed_year_active_discount_amount", 0.38095238095238093], ["lf_project_activities", "last_completed_year_active_invoice_amount", 0.3870967741935484], ["lf_project_activities", "last_completed_year_active_membership_count", 0.375], ["lf_project_activities", "last_completed_year_active_membership_revenue", 0.36363636363636365], ["lf_project_activities", "lf_project_activities", 1.0], ["lf_project_activities", "main_branch_commits", 0.3], ["lf_project_activities", "membership_revenue", 0.2564102564102564], ["lf_project_activities", "past_event_speakers", 0.3], ["lf_project_activities", "past_events_count", 0.2631578947368421], ["lf_project_activities", "project_count", 0.5882352941176471], ["lf_project_activities", "project_health_count", 0.5365853658536586], ["lf_project_activities", "renewal_price", 0.29411764705882354], ["lf_project_activities", "sponsorship_quantity_total", 0.2127659574468085], ["lf_project_activities", "total_accepted_proposals", 0.3111111111111111], ["lf_project_activities", "total_activities", 0.6486486486486487], ["lf_project_activities", "total_certifications", 0.43902439024390244], ["lf_project_activities", "total_code_deletions", 0.3902439024390244], ["lf_project_activities", "total_code_insertions", 0.2857142857142857], ["lf_project_activities", "total_contributing_organizations", 0.33962264150943394], ["lf_project_activities", "total_contributors", 0.2564102564102564], ["lf_project_activities", "total_discount_amount", 0.3333333333333333], ["lf_project_activities", "total_downgrade_churn_amount", 0.32653061224489793], ["lf_project_activities", "total_enrolled_users", 0.3902439024390244], ["lf_project_activities", "total_enrollments", 0.3684210526315789], ["lf_project_activities", "total_estimated_cost", 0.3902439024390244], ["lf_project_activities", "total_event_registrations_goal", 0.35294117647058826], ["lf_project_activities", "total_events", 0.30303030303030304], ["lf_project_activities", "total_first_time_contributors", 0.4], ["lf_project_activities", "total_gross_revenue", 0.35], ["lf_project_activities", "total_invoice_amount", 0.34146341463414637], ["lf_project_activities", "total_maintainer_records", 0.26666666666666666], ["lf_project_activities", "total_maintainers", 0.21052631578947367], ["lf_project_activities", "total_next_membership_revenue", 0.32], ["lf_project_activities", "total_registration_net_revenue", 0.35294117647058826], ["lf_project_activities", "total_registration_tax", 0.4186046511627907], ["lf_project_activities", "total_registrations", 0.45], ["lf_project_activities", "total_software_value", 0.1951219512195122], ["lf_project_activities", "total_speakers", 0.2857142857142857], ["lf_project_activities", "total_speaking_engagements", 0.2553191489361702], ["lf_project_activities", "total_sponsorship_count", 0.2727272727272727], ["lf_project_activities", "total_sponsorship_revenue", 0.30434782608695654], ["lf_project_activities", "training_enrollments", 0.2926829268292683], ["lf_project_activities", "upcoming_events_count", 0.14285714285714285], ["main_branch_commits", "active_maintainer_records", 0.4090909090909091], ["main_branch_commits", "active_maintainers", 0.32432432432432434], ["main_branch_commits", "approved_pull_requests", 0.24390243902439024], ["main_branch_commits", "avg_project_health_score", 0.37209302325581395], ["main_branch_commits", "bot_activities", 0.30303030303030304], ["main_branch_commits", "certification_enrollments", 0.36363636363636365], ["main_branch_commits", "churned_membership_count", 0.27906976744186046], ["main_branch_commits", "churned_membership_discount_amount", 0.2641509433962264], ["main_branch_commits", "churned_membership_invoice_amount", 0.2692307692307692], ["main_branch_commits", "code_contribution_activities", 0.2978723404255319], ["main_branch_commits", "current_membership_count", 0.27906976744186046], ["main_branch_commits", "current_membership_discount_amount", 0.2641509433962264], ["main_branch_commits", "current_membership_invoice_amount", 0.2692307692307692], ["main_branch_commits", "current_membership_revenue", 0.13333333333333333], ["main_branch_commits", "current_new_account_membership_count", 0.21818181818181817], ["main_branch_commits", "human_activities", 0.5142857142857142], ["main_branch_commits", "last_completed_year_active_discount_amount", 0.22950819672131148], ["main_branch_commits", "last_completed_year_active_invoice_amount", 0.23333333333333334], ["main_branch_commits", "last_completed_year_active_membership_count", 0.25806451612903225], ["main_branch_commits", "last_completed_year_active_membership_revenue", 0.21875], ["main_branch_commits", "lf_project_activities", 0.25], ["main_branch_commits", "main_branch_commits", 1.0], ["main_branch_commits", "membership_revenue", 0.16216216216216217], ["main_branch_commits", "past_event_speakers", 0.2631578947368421], ["main_branch_commits", "past_events_count", 0.3333333333333333], ["main_branch_commits", "project_count", 0.375], ["main_branch_commits", "project_health_count", 0.3076923076923077], ["main_branch_commits", "renewal_price", 0.1875], ["main_branch_commits", "sponsorship_quantity_total", 0.26666666666666666], ["main_branch_commits", "total_accepted_proposals", 0.23255813953488372], ["main_branch_commits", "total_activities", 0.2857142857142857], ["main_branch_commits", "total_certifications", 0.2564102564102564], ["main_branch_commits", "total_code_deletions", 0.3076923076923077], ["main_branch_commits", "total_code_insertions", 0.35], ["main_branch_commits", "total_contributing_organizations", 0.27450980392156865], ["main_branch_commits", "total_contributors", 0.3783783783783784], ["main_branch_commits", "total_discount_amount", 0.3], ["main_branch_commits", "total_downgrade_churn_amount", 0.2553191489361702], ["main_branch_commits", "total_enrolled_users", 0.2564102564102564], ["main_branch_commits", "total_enrollments", 0.2222222222222222], ["main_branch_commits", "total_estimated_cost", 0.3076923076923077], ["main_branch_commits", "total_event_registrations_goal", 0.2857142857142857], ["main_branch_commits", "total_events", 0.25806451612903225], ["main_branch_commits", "total_first_time_contributors", 0.2916666666666667], ["main_branch_commits", "total_gross_revenue", 0.10526315789473684], ["main_branch_commits", "total_invoice_amount", 0.358974358974359], ["main_branch_commits", "total_maintainer_records", 0.4186046511627907], ["main_branch_commits", "total_maintainers", 0.3333333333333333], ["main_branch_commits", "total_next_membership_revenue", 0.125], ["main_branch_commits", "total_registration_net_revenue", 0.24489795918367346], ["main_branch_commits", "total_registration_tax", 0.24390243902439024], ["main_branch_commits", "total_registrations", 0.3157894736842105], ["main_branch_commits", "total_software_value", 0.20512820512820512], ["main_branch_commits", "total_speakers", 0.24242424242424243], ["main_branch_commits", "total_speaking_engagements", 0.35555555555555557], ["main_branch_commits", "total_sponsorship_count", 0.2857142857142857], ["main_branch_commits", "total_sponsorship_revenue", 0.13636363636363635], ["main_branch_commits", "training_enrollments", 0.41025641025641024], ["main_branch_commits", "upcoming_events_count", 0.45], ["membership_revenue", "active_maintainer_records", 0.27906976744186046], ["membership_revenue", "active_maintainers", 0.2222222222222222], ["membership_revenue", "approved_pull_requests", 0.35], ["membership_revenue", "avg_project_health_score", 0.23809523809523808], ["membership_revenue", "bot_activities", 0.125], ["membership_revenue", "certification_enrollments", 0.13953488372093023], ["membership_revenue", "churned_membership_count", 0.5714285714285714], ["membership_revenue", "churned_membership_discount_amount", 0.5], ["membership_revenue", "churned_membership_invoice_amount", 0.5098039215686274], ["membership_revenue", "code_contribution_activities", 0.17391304347826086], ["membership_revenue", "current_membership_count", 0.5714285714285714], ["membership_revenue", "current_membership_discount_amount", 0.5], ["membership_revenue", "current_membership_invoice_amount", 0.5098039215686274], ["membership_revenue", "current_membership_revenue", 0.8181818181818182], ["membership_revenue", "current_new_account_membership_count", 0.4444444444444444], ["membership_revenue", "human_activities", 0.17647058823529413], ["membership_revenue", "last_completed_year_active_discount_amount", 0.3], ["membership_revenue", "last_completed_year_active_invoice_amount", 0.3050847457627119], ["membership_revenue", "last_completed_year_active_membership_count", 0.39344262295081966], ["membership_revenue", "last_completed_year_active_membership_revenue", 0.5714285714285714], ["membership_revenue", "lf_project_activities", 0.15384615384615385], ["membership_revenue", "main_branch_commits", 0.16216216216216217], ["membership_revenue", "membership_revenue", 1.0], ["membership_revenue", "past_event_speakers", 0.3783783783783784], ["membership_revenue", "past_events_count", 0.4], ["membership_revenue", "project_count", 0.1935483870967742], ["membership_revenue", "project_health_count", 0.2631578947368421], ["membership_revenue", "renewal_price", 0.25806451612903225], ["membership_revenue", "sponsorship_quantity_total", 0.3181818181818182], ["membership_revenue", "total_accepted_proposals", 0.19047619047619047], ["membership_revenue", "total_activities", 0.11764705882352941], ["membership_revenue", "total_certifications", 0.15789473684210525], ["membership_revenue", "total_code_deletions", 0.15789473684210525], ["membership_revenue", "total_code_insertions", 0.20512820512820512], ["membership_revenue", "total_contributing_organizations", 0.12], ["membership_revenue", "total_contributors", 0.16666666666666666], ["membership_revenue", "total_discount_amount", 0.10256410256410256], ["membership_revenue", "total_downgrade_churn_amount", 0.08695652173913043], ["membership_revenue", "total_enrolled_users", 0.21052631578947367], ["membership_revenue", "total_enrollments", 0.17142857142857143], ["membership_revenue", "total_estimated_cost", 0.15789473684210525], ["membership_revenue", "total_event_registrations_goal", 0.25], ["membership_revenue", "total_events", 0.3333333333333333], ["membership_revenue", "total_first_time_contributors", 0.2127659574468085], ["membership_revenue", "total_gross_revenue", 0.5405405405405406], ["membership_revenue", "total_invoice_amount", 0.10526315789473684], ["membership_revenue", "total_maintainer_records", 0.2857142857142857], ["membership_revenue", "total_maintainers", 0.22857142857142856], ["membership_revenue", "total_next_membership_revenue", 0.7659574468085106], ["membership_revenue", "total_registration_net_revenue", 0.4166666666666667], ["membership_revenue", "total_registration_tax", 0.2], ["membership_revenue", "total_registrations", 0.21621621621621623], ["membership_revenue", "total_software_value", 0.3157894736842105], ["membership_revenue", "total_speakers", 0.25], ["membership_revenue", "total_speaking_engagements", 0.13636363636363635], ["membership_revenue", "total_sponsorship_count", 0.34146341463414637], ["membership_revenue", "total_sponsorship_revenue", 0.6046511627906976], ["membership_revenue", "training_enrollments", 0.15789473684210525], ["membership_revenue", "upcoming_events_count", 0.41025641025641024], ["past_event_speakers", "active_maintainer_records", 0.45454545454545453], ["past_event_speakers", "active_maintainers", 0.5405405405405406], ["past_event_speakers", "approved_pull_requests", 0.1951219512195122], ["past_event_speakers", "avg_project_health_score", 0.37209302325581395], ["past_event_speakers", "bot_activities", 0.24242424242424243], ["past_event_speakers", "certification_enrollments", 0.36363636363636365], ["past_event_speakers", "churned_membership_count", 0.23255813953488372], ["past_event_speakers", "churned_membership_discount_amount", 0.22641509433962265], ["past_event_speakers", "churned_membership_invoice_amount", 0.19230769230769232], ["past_event_speakers", "code_contribution_activities", 0.2127659574468085], ["past_event_speakers", "current_membership_count", 0.37209302325581395], ["past_event_speakers", "current_membership_discount_amount", 0.3018867924528302], ["past_event_speakers", "current_membership_invoice_amount", 0.3076923076923077], ["past_event_speakers", "current_membership_revenue", 0.3111111111111111], ["past_event_speakers", "current_new_account_membership_count", 0.32727272727272727], ["past_event_speakers", "human_activities", 0.11428571428571428], ["past_event_speakers", "last_completed_year_active_discount_amount", 0.36065573770491804], ["past_event_speakers", "last_completed_year_active_invoice_amount", 0.3], ["past_event_speakers", "last_completed_year_active_membership_count", 0.3870967741935484], ["past_event_speakers", "last_completed_year_active_membership_revenue", 0.28125], ["past_event_speakers", "lf_project_activities", 0.25], ["past_event_speakers", "main_branch_commits", 0.10526315789473684], ["past_event_speakers", "membership_revenue", 0.3783783783783784], ["past_event_speakers", "past_event_speakers", 1.0], ["past_event_speakers", "past_events_count", 0.6111111111111112], ["past_event_speakers", "project_count", 0.3125], ["past_event_speakers", "project_health_count", 0.3076923076923077], ["past_event_speakers", "renewal_price", 0.3125], ["past_event_speakers", "sponsorship_quantity_total", 0.26666666666666666], ["past_event_speakers", "total_accepted_proposals", 0.13953488372093023], ["past_event_speakers", "total_activities", 0.11428571428571428], ["past_event_speakers", "total_certifications", 0.2564102564102564], ["past_event_speakers", "total_code_deletions", 0.10256410256410256], ["past_event_speakers", "total_code_insertions", 0.25], ["past_event_speakers", "total_contributing_organizations", 0.23529411764705882], ["past_event_speakers", "total_contributors", 0.32432432432432434], ["past_event_speakers", "total_discount_amount", 0.3], ["past_event_speakers", "total_downgrade_churn_amount", 0.2127659574468085], ["past_event_speakers", "total_enrolled_users", 0.46153846153846156], ["past_event_speakers", "total_enrollments", 0.3888888888888889], ["past_event_speakers", "total_estimated_cost", 0.2564102564102564], ["past_event_speakers", "total_event_registrations_goal", 0.4489795918367347], ["past_event_speakers", "total_events", 0.5161290322580645], ["past_event_speakers", "total_first_time_contributors", 0.375], ["past_event_speakers", "total_gross_revenue", 0.42105263157894735], ["past_event_speakers", "total_invoice_amount", 0.2564102564102564], ["past_event_speakers", "total_maintainer_records", 0.37209302325581395], ["past_event_speakers", "total_maintainers", 0.4444444444444444], ["past_event_speakers", "total_next_membership_revenue", 0.3333333333333333], ["past_event_speakers", "total_registration_net_revenue", 0.3673469387755102], ["past_event_speakers", "total_registration_tax", 0.2926829268292683], ["past_event_speakers", "total_registrations", 0.2631578947368421], ["past_event_speakers", "total_software_value", 0.3076923076923077], ["past_event_speakers", "total_speakers", 0.6060606060606061], ["past_event_speakers", "total_speaking_engagements", 0.4], ["past_event_speakers", "total_sponsorship_count", 0.2857142857142857], ["past_event_speakers", "total_sponsorship_revenue", 0.36363636363636365], ["past_event_speakers", "training_enrollments", 0.358974358974359], ["past_event_speakers", "upcoming_events_count", 0.4], ["past_events_count", "active_maintainer_records", 0.42857142857142855], ["past_events_count", "active_maintainers", 0.4], ["past_events_count", "approved_pull_requests", 0.20512820512820512], ["past_events_count", "avg_project_health_score", 0.3902439024390244], ["past_events_count", "bot_activities", 0.25806451612903225], ["past_events_count", "certification_enrollments", 0.38095238095238093], ["past_events_count", "churned_membership_count", 0.34146341463414637], ["past_events_count", "churned_membership_discount_amount", 0.27450980392156865], ["past_events_count", "churned_membership_invoice_amount", 0.24], ["past_events_count", "code_contribution_activities", 0.26666666666666666], ["past_events_count", "current_membership_count", 0.4878048780487805], ["past_events_count", "current_membership_discount_amount", 0.39215686274509803], ["past_events_count", "current_membership_invoice_amount", 0.4], ["past_events_count", "current_membership_revenue", 0.32558139534883723], ["past_events_count", "current_new_account_membership_count", 0.37735849056603776], ["past_events_count", "human_activities", 0.12121212121212122], ["past_events_count", "last_completed_year_active_discount_amount", 0.4406779661016949], ["past_events_count", "last_completed_year_active_invoice_amount", 0.4482758620689655], ["past_events_count", "last_completed_year_active_membership_count", 0.4666666666666667], ["past_events_count", "last_completed_year_active_membership_revenue", 0.2903225806451613], ["past_events_count", "lf_project_activities", 0.2631578947368421], ["past_events_count", "main_branch_commits", 0.3888888888888889], ["past_events_count", "membership_revenue", 0.4], ["past_events_count", "past_event_speakers", 0.6111111111111112], ["past_events_count", "past_events_count", 1.0], ["past_events_count", "project_count", 0.5333333333333333], ["past_events_count", "project_health_count", 0.5945945945945946], ["past_events_count", "renewal_price", 0.26666666666666666], ["past_events_count", "sponsorship_quantity_total", 0.32558139534883723], ["past_events_count", "total_accepted_proposals", 0.14634146341463414], ["past_events_count", "total_activities", 0.12121212121212122], ["past_events_count", "total_certifications", 0.2702702702702703], ["past_events_count", "total_code_deletions", 0.2702702702702703], ["past_events_count", "total_code_insertions", 0.3157894736842105], ["past_events_count", "total_contributing_organizations", 0.24489795918367346], ["past_events_count", "total_contributors", 0.34285714285714286], ["past_events_count", "total_discount_amount", 0.3684210526315789], ["past_events_count", "total_downgrade_churn_amount", 0.4], ["past_events_count", "total_enrolled_users", 0.2702702702702703], ["past_events_count", "total_enrollments", 0.4117647058823529], ["past_events_count", "total_estimated_cost", 0.43243243243243246], ["past_events_count", "total_event_registrations_goal", 0.425531914893617], ["past_events_count", "total_events", 0.5517241379310345], ["past_events_count", "total_first_time_contributors", 0.43478260869565216], ["past_events_count", "total_gross_revenue", 0.4444444444444444], ["past_events_count", "total_invoice_amount", 0.43243243243243246], ["past_events_count", "total_maintainer_records", 0.34146341463414637], ["past_events_count", "total_maintainers", 0.29411764705882354], ["past_events_count", "total_next_membership_revenue", 0.34782608695652173], ["past_events_count", "total_registration_net_revenue", 0.3829787234042553], ["past_events_count", "total_registration_tax", 0.2564102564102564], ["past_events_count", "total_registrations", 0.2777777777777778], ["past_events_count", "total_software_value", 0.2702702702702703], ["past_events_count", "total_speakers", 0.1935483870967742], ["past_events_count", "total_speaking_engagements", 0.37209302325581395], ["past_events_count", "total_sponsorship_count", 0.45], ["past_events_count", "total_sponsorship_revenue", 0.38095238095238093], ["past_events_count", "training_enrollments", 0.3783783783783784], ["past_events_count", "upcoming_events_count", 0.7368421052631579], ["project_count", "active_maintainer_records", 0.21052631578947367], ["project_count", "active_maintainers", 0.3225806451612903], ["project_count", "approved_pull_requests", 0.2857142857142857], ["project_count", "avg_project_health_score", 0.5405405405405406], ["project_count", "bot_activities", 0.2962962962962963], ["project_count", "certification_enrollments", 0.2631578947368421], ["project_count", "churned_membership_count", 0.3783783783783784], ["project_count", "churned_membership_discount_amount", 0.2978723404255319], ["project_count", "churned_membership_invoice_amount", 0.34782608695652173], ["project_count", "code_contribution_activities", 0.34146341463414637], ["project_count", "current_membership_count", 0.3783783783783784], ["project_count", "current_membership_discount_amount", 0.3829787234042553], ["project_count", "current_membership_invoice_amount", 0.391304347826087], ["project_count", "current_membership_revenue", 0.2564102564102564], ["project_count", "current_new_account_membership_count", 0.2857142857142857], ["project_count", "human_activities", 0.20689655172413793], ["project_count", "last_completed_year_active_discount_amount", 0.36363636363636365], ["project_count", "last_completed_year_active_invoice_amount", 0.25925925925925924], ["project_count", "last_completed_year_active_membership_count", 0.35714285714285715], ["project_count", "last_completed_year_active_membership_revenue", 0.1724137931034483], ["project_count", "lf_project_activities", 0.5882352941176471], ["project_count", "main_branch_commits", 0.375], ["project_count", "membership_revenue", 0.25806451612903225], ["project_count", "past_event_speakers", 0.3125], ["project_count", "past_events_count", 0.6], ["project_count", "project_count", 1.0], ["project_count", "project_health_count", 0.7878787878787878], ["project_count", "renewal_price", 0.23076923076923078], ["project_count", "sponsorship_quantity_total", 0.3076923076923077], ["project_count", "total_accepted_proposals", 0.21621621621621623], ["project_count", "total_activities", 0.27586206896551724], ["project_count", "total_certifications", 0.36363636363636365], ["project_count", "total_code_deletions", 0.36363636363636365], ["project_count", "total_code_insertions", 0.4117647058823529], ["project_count", "total_contributing_organizations", 0.3111111111111111], ["project_count", "total_contributors", 0.45161290322580644], ["project_count", "total_discount_amount", 0.47058823529411764], ["project_count", "total_downgrade_churn_amount", 0.3902439024390244], ["project_count", "total_enrolled_users", 0.30303030303030304], ["project_count", "total_enrollments", 0.3333333333333333], ["project_count", "total_estimated_cost", 0.42424242424242425], ["project_count", "total_event_registrations_goal", 0.27906976744186046], ["project_count", "total_events", 0.32], ["project_count", "total_first_time_contributors", 0.3333333333333333], ["project_count", "total_gross_revenue", 0.25], ["project_count", "total_invoice_amount", 0.42424242424242425], ["project_count", "total_maintainer_records", 0.21621621621621623], ["project_count", "total_maintainers", 0.3333333333333333], ["project_count", "total_next_membership_revenue", 0.23809523809523808], ["project_count", "total_registration_net_revenue", 0.27906976744186046], ["project_count", "total_registration_tax", 0.17142857142857143], ["project_count", "total_registrations", 0.1875], ["project_count", "total_software_value", 0.24242424242424243], ["project_count", "total_speakers", 0.14814814814814814], ["project_count", "total_speaking_engagements", 0.2564102564102564], ["project_count", "total_sponsorship_count", 0.4444444444444444], ["project_count", "total_sponsorship_revenue", 0.21052631578947367], ["project_count", "training_enrollments", 0.30303030303030304], ["project_count", "upcoming_events_count", 0.5882352941176471], ["project_health_count", "active_maintainer_records", 0.17777777777777778], ["project_health_count", "active_maintainers", 0.3157894736842105], ["project_health_count", "approved_pull_requests", 0.23809523809523808], ["project_health_count", "avg_project_health_score", 0.7727272727272727], ["project_health_count", "bot_activities", 0.23529411764705882], ["project_health_count", "certification_enrollments", 0.2222222222222222], ["project_health_count", "churned_membership_count", 0.3181818181818182], ["project_health_count", "churned_membership_discount_amount", 0.25925925925925924], ["project_health_count", "churned_membership_invoice_amount", 0.33962264150943394], ["project_health_count", "code_contribution_activities", 0.2916666666666667], ["project_health_count", "current_membership_count", 0.5], ["project_health_count", "current_membership_discount_amount", 0.4074074074074074], ["project_health_count", "current_membership_invoice_amount", 0.41509433962264153], ["project_health_count", "current_membership_revenue", 0.30434782608695654], ["project_health_count", "current_new_account_membership_count", 0.39285714285714285], ["project_health_count", "human_activities", 0.16666666666666666], ["project_health_count", "last_completed_year_active_discount_amount", 0.3225806451612903], ["project_health_count", "last_completed_year_active_invoice_amount", 0.36065573770491804], ["project_health_count", "last_completed_year_active_membership_count", 0.38095238095238093], ["project_health_count", "last_completed_year_active_membership_revenue", 0.15384615384615385], ["project_health_count", "lf_project_activities", 0.43902439024390244], ["project_health_count", "main_branch_commits", 0.358974358974359], ["project_health_count", "membership_revenue", 0.2631578947368421], ["project_health_count", "past_event_speakers", 0.2564102564102564], ["project_health_count", "past_events_count", 0.5945945945945946], ["project_health_count", "project_count", 0.7878787878787878], ["project_health_count", "project_health_count", 1.0], ["project_health_count", "renewal_price", 0.18181818181818182], ["project_health_count", "sponsorship_quantity_total", 0.2608695652173913], ["project_health_count", "total_accepted_proposals", 0.22727272727272727], ["project_health_count", "total_activities", 0.2222222222222222], ["project_health_count", "total_certifications", 0.4], ["project_health_count", "total_code_deletions", 0.4], ["project_health_count", "total_code_insertions", 0.43902439024390244], ["project_health_count", "total_contributing_organizations", 0.34615384615384615], ["project_health_count", "total_contributors", 0.47368421052631576], ["project_health_count", "total_discount_amount", 0.4878048780487805], ["project_health_count", "total_downgrade_churn_amount", 0.4166666666666667], ["project_health_count", "total_enrolled_users", 0.25], ["project_health_count", "total_enrollments", 0.2702702702702703], ["project_health_count", "total_estimated_cost", 0.45], ["project_health_count", "total_event_registrations_goal", 0.28], ["project_health_count", "total_events", 0.4375], ["project_health_count", "total_first_time_contributors", 0.3673469387755102], ["project_health_count", "total_gross_revenue", 0.2564102564102564], ["project_health_count", "total_invoice_amount", 0.5], ["project_health_count", "total_maintainer_records", 0.18181818181818182], ["project_health_count", "total_maintainers", 0.3783783783783784], ["project_health_count", "total_next_membership_revenue", 0.2857142857142857], ["project_health_count", "total_registration_net_revenue", 0.28], ["project_health_count", "total_registration_tax", 0.3333333333333333], ["project_health_count", "total_registrations", 0.358974358974359], ["project_health_count", "total_software_value", 0.35], ["project_health_count", "total_speakers", 0.17647058823529413], ["project_health_count", "total_speaking_engagements", 0.2608695652173913], ["project_health_count", "total_sponsorship_count", 0.5116279069767442], ["project_health_count", "total_sponsorship_revenue", 0.3111111111111111], ["project_health_count", "training_enrollments", 0.25], ["project_health_count", "upcoming_events_count", 0.4878048780487805], ["renewal_price", "active_maintainer_records", 0.15789473684210525], ["renewal_price", "active_maintainers", 0.25806451612903225], ["renewal_price", "approved_pull_requests", 0.17142857142857143], ["renewal_price", "avg_project_health_score", 0.32432432432432434], ["renewal_price", "bot_activities", 0.07407407407407407], ["renewal_price", "certification_enrollments", 0.21052631578947367], ["renewal_price", "churned_membership_count", 0.32432432432432434], ["renewal_price", "churned_membership_discount_amount", 0.1702127659574468], ["renewal_price", "churned_membership_invoice_amount", 0.34782608695652173], ["renewal_price", "code_contribution_activities", 0.2926829268292683], ["renewal_price", "current_membership_count", 0.32432432432432434], ["renewal_price", "current_membership_discount_amount", 0.2127659574468085], ["renewal_price", "current_membership_invoice_amount", 0.34782608695652173], ["renewal_price", "current_membership_revenue", 0.358974358974359], ["renewal_price", "current_new_account_membership_count", 0.3673469387755102], ["renewal_price", "human_activities", 0.06896551724137931], ["renewal_price", "last_completed_year_active_discount_amount", 0.14545454545454545], ["renewal_price", "last_completed_year_active_invoice_amount", 0.2222222222222222], ["renewal_price", "last_completed_year_active_membership_count", 0.10714285714285714], ["renewal_price", "last_completed_year_active_membership_revenue", 0.13793103448275862], ["renewal_price", "lf_project_activities", 0.35294117647058826], ["renewal_price", "main_branch_commits", 0.25], ["renewal_price", "membership_revenue", 0.25806451612903225], ["renewal_price", "past_event_speakers", 0.3125], ["renewal_price", "past_events_count", 0.26666666666666666], ["renewal_price", "project_count", 0.23076923076923078], ["renewal_price", "project_health_count", 0.42424242424242425], ["renewal_price", "renewal_price", 1.0], ["renewal_price", "sponsorship_quantity_total", 0.20512820512820512], ["renewal_price", "total_accepted_proposals", 0.2702702702702703], ["renewal_price", "total_activities", 0.3448275862068966], ["renewal_price", "total_certifications", 0.36363636363636365], ["renewal_price", "total_code_deletions", 0.24242424242424243], ["renewal_price", "total_code_insertions", 0.29411764705882354], ["renewal_price", "total_contributing_organizations", 0.2222222222222222], ["renewal_price", "total_contributors", 0.3225806451612903], ["renewal_price", "total_discount_amount", 0.29411764705882354], ["renewal_price", "total_downgrade_churn_amount", 0.24390243902439024], ["renewal_price", "total_enrolled_users", 0.30303030303030304], ["renewal_price", "total_enrollments", 0.3333333333333333], ["renewal_price", "total_estimated_cost", 0.30303030303030304], ["renewal_price", "total_event_registrations_goal", 0.23255813953488372], ["renewal_price", "total_events", 0.32], ["renewal_price", "total_first_time_contributors", 0.23809523809523808], ["renewal_price", "total_gross_revenue", 0.3125], ["renewal_price", "total_invoice_amount", 0.36363636363636365], ["renewal_price", "total_maintainer_records", 0.2702702702702703], ["renewal_price", "total_maintainers", 0.26666666666666666], ["renewal_price", "total_next_membership_revenue", 0.2857142857142857], ["renewal_price", "total_registration_net_revenue", 0.27906976744186046], ["renewal_price", "total_registration_tax", 0.2857142857142857], ["renewal_price", "total_registrations", 0.3125], ["renewal_price", "total_software_value", 0.30303030303030304], ["renewal_price", "total_speakers", 0.37037037037037035], ["renewal_price", "total_speaking_engagements", 0.3076923076923077], ["renewal_price", "total_sponsorship_count", 0.3888888888888889], ["renewal_price", "total_sponsorship_revenue", 0.3684210526315789], ["renewal_price", "training_enrollments", 0.24242424242424243], ["renewal_price", "upcoming_events_count", 0.23529411764705882], ["sponsorship_quantity_total", "active_maintainer_records", 0.1568627450980392], ["sponsorship_quantity_total", "active_maintainers", 0.13636363636363635], ["sponsorship_quantity_total", "approved_pull_requests", 0.25], ["sponsorship_quantity_total", "avg_project_health_score", 0.12], ["sponsorship_quantity_total", "bot_activities", 0.3], ["sponsorship_quantity_total", "certification_enrollments", 0.19607843137254902], ["sponsorship_quantity_total", "churned_membership_count", 0.4], ["sponsorship_quantity_total", "churned_membership_discount_amount", 0.36666666666666664], ["sponsorship_quantity_total", "churned_membership_invoice_amount", 0.3389830508474576], ["sponsorship_quantity_total", "code_contribution_activities", 0.3333333333333333], ["sponsorship_quantity_total", "current_membership_count", 0.4], ["sponsorship_quantity_total", "current_membership_discount_amount", 0.36666666666666664], ["sponsorship_quantity_total", "current_membership_invoice_amount", 0.3389830508474576], ["sponsorship_quantity_total", "current_membership_revenue", 0.3076923076923077], ["sponsorship_quantity_total", "current_new_account_membership_count", 0.3548387096774194], ["sponsorship_quantity_total", "human_activities", 0.3333333333333333], ["sponsorship_quantity_total", "last_completed_year_active_discount_amount", 0.20588235294117646], ["sponsorship_quantity_total", "last_completed_year_active_invoice_amount", 0.208955223880597], ["sponsorship_quantity_total", "last_completed_year_active_membership_count", 0.3188405797101449], ["sponsorship_quantity_total", "last_completed_year_active_membership_revenue", 0.2535211267605634], ["sponsorship_quantity_total", "lf_project_activities", 0.2978723404255319], ["sponsorship_quantity_total", "main_branch_commits", 0.26666666666666666], ["sponsorship_quantity_total", "membership_revenue", 0.3181818181818182], ["sponsorship_quantity_total", "past_event_speakers", 0.17777777777777778], ["sponsorship_quantity_total", "past_events_count", 0.23255813953488372], ["sponsorship_quantity_total", "project_count", 0.3076923076923077], ["sponsorship_quantity_total", "project_health_count", 0.2608695652173913], ["sponsorship_quantity_total", "renewal_price", 0.15384615384615385], ["sponsorship_quantity_total", "sponsorship_quantity_total", 1.0], ["sponsorship_quantity_total", "total_accepted_proposals", 0.2], ["sponsorship_quantity_total", "total_activities", 0.23809523809523808], ["sponsorship_quantity_total", "total_certifications", 0.21739130434782608], ["sponsorship_quantity_total", "total_code_deletions", 0.21739130434782608], ["sponsorship_quantity_total", "total_code_insertions", 0.2127659574468085], ["sponsorship_quantity_total", "total_contributing_organizations", 0.1724137931034483], ["sponsorship_quantity_total", "total_contributors", 0.22727272727272727], ["sponsorship_quantity_total", "total_discount_amount", 0.2127659574468085], ["sponsorship_quantity_total", "total_downgrade_churn_amount", 0.18518518518518517], ["sponsorship_quantity_total", "total_enrolled_users", 0.21739130434782608], ["sponsorship_quantity_total", "total_enrollments", 0.23255813953488372], ["sponsorship_quantity_total", "total_estimated_cost", 0.21739130434782608], ["sponsorship_quantity_total", "total_event_registrations_goal", 0.17857142857142858], ["sponsorship_quantity_total", "total_events", 0.2631578947368421], ["sponsorship_quantity_total", "total_first_time_contributors", 0.18181818181818182], ["sponsorship_quantity_total", "total_gross_revenue", 0.2222222222222222], ["sponsorship_quantity_total", "total_invoice_amount", 0.21739130434782608], ["sponsorship_quantity_total", "total_maintainer_records", 0.2], ["sponsorship_quantity_total", "total_maintainers", 0.23255813953488372], ["sponsorship_quantity_total", "total_next_membership_revenue", 0.32727272727272727], ["sponsorship_quantity_total", "total_registration_net_revenue", 0.17857142857142858], ["sponsorship_quantity_total", "total_registration_tax", 0.20833333333333334], ["sponsorship_quantity_total", "total_registrations", 0.2222222222222222], ["sponsorship_quantity_total", "total_software_value", 0.21739130434782608], ["sponsorship_quantity_total", "total_speakers", 0.25], ["sponsorship_quantity_total", "total_speaking_engagements", 0.19230769230769232], ["sponsorship_quantity_total", "total_sponsorship_count", 0.6122448979591837], ["sponsorship_quantity_total", "total_sponsorship_revenue", 0.5098039215686274], ["sponsorship_quantity_total", "training_enrollments", 0.13043478260869565], ["sponsorship_quantity_total", "upcoming_events_count", 0.2978723404255319], ["total_accepted_proposals", "active_maintainer_records", 0.2857142857142857], ["total_accepted_proposals", "active_maintainers", 0.2857142857142857], ["total_accepted_proposals", "approved_pull_requests", 0.34782608695652173], ["total_accepted_proposals", "avg_project_health_score", 0.3333333333333333], ["total_accepted_proposals", "bot_activities", 0.3684210526315789], ["total_accepted_proposals", "certification_enrollments", 0.2857142857142857], ["total_accepted_proposals", "churned_membership_count", 0.25], ["total_accepted_proposals", "churned_membership_discount_amount", 0.2413793103448276], ["total_accepted_proposals", "churned_membership_invoice_amount", 0.24561403508771928], ["total_accepted_proposals", "code_contribution_activities", 0.2692307692307692], ["total_accepted_proposals", "current_membership_count", 0.125], ["total_accepted_proposals", "current_membership_discount_amount", 0.20689655172413793], ["total_accepted_proposals", "current_membership_invoice_amount", 0.17543859649122806], ["total_accepted_proposals", "current_membership_revenue", 0.2], ["total_accepted_proposals", "current_new_account_membership_count", 0.26666666666666666], ["total_accepted_proposals", "human_activities", 0.3], ["total_accepted_proposals", "last_completed_year_active_discount_amount", 0.3333333333333333], ["total_accepted_proposals", "last_completed_year_active_invoice_amount", 0.3384615384615385], ["total_accepted_proposals", "last_completed_year_active_membership_count", 0.29850746268656714], ["total_accepted_proposals", "last_completed_year_active_membership_revenue", 0.2898550724637681], ["total_accepted_proposals", "lf_project_activities", 0.26666666666666666], ["total_accepted_proposals", "main_branch_commits", 0.09302325581395349], ["total_accepted_proposals", "membership_revenue", 0.14285714285714285], ["total_accepted_proposals", "past_event_speakers", 0.27906976744186046], ["total_accepted_proposals", "past_events_count", 0.14634146341463414], ["total_accepted_proposals", "project_count", 0.21621621621621623], ["total_accepted_proposals", "project_health_count", 0.22727272727272727], ["total_accepted_proposals", "renewal_price", 0.2702702702702703], ["total_accepted_proposals", "sponsorship_quantity_total", 0.2], ["total_accepted_proposals", "total_accepted_proposals", 1.0], ["total_accepted_proposals", "total_activities", 0.5], ["total_accepted_proposals", "total_certifications", 0.5], ["total_accepted_proposals", "total_code_deletions", 0.5], ["total_accepted_proposals", "total_code_insertions", 0.4888888888888889], ["total_accepted_proposals", "total_contributing_organizations", 0.35714285714285715], ["total_accepted_proposals", "total_contributors", 0.5238095238095238], ["total_accepted_proposals", "total_discount_amount", 0.35555555555555557], ["total_accepted_proposals", "total_downgrade_churn_amount", 0.34615384615384615], ["total_accepted_proposals", "total_enrolled_users", 0.5454545454545454], ["total_accepted_proposals", "total_enrollments", 0.4878048780487805], ["total_accepted_proposals", "total_estimated_cost", 0.5909090909090909], ["total_accepted_proposals", "total_event_registrations_goal", 0.4444444444444444], ["total_accepted_proposals", "total_events", 0.5], ["total_accepted_proposals", "total_first_time_contributors", 0.41509433962264153], ["total_accepted_proposals", "total_gross_revenue", 0.46511627906976744], ["total_accepted_proposals", "total_invoice_amount", 0.4090909090909091], ["total_accepted_proposals", "total_maintainer_records", 0.4166666666666667], ["total_accepted_proposals", "total_maintainers", 0.4878048780487805], ["total_accepted_proposals", "total_next_membership_revenue", 0.33962264150943394], ["total_accepted_proposals", "total_registration_net_revenue", 0.37037037037037035], ["total_accepted_proposals", "total_registration_tax", 0.43478260869565216], ["total_accepted_proposals", "total_registrations", 0.46511627906976744], ["total_accepted_proposals", "total_software_value", 0.5], ["total_accepted_proposals", "total_speakers", 0.5263157894736842], ["total_accepted_proposals", "total_speaking_engagements", 0.4], ["total_accepted_proposals", "total_sponsorship_count", 0.425531914893617], ["total_accepted_proposals", "total_sponsorship_revenue", 0.40816326530612246], ["total_accepted_proposals", "training_enrollments", 0.3181818181818182], ["total_accepted_proposals", "upcoming_events_count", 0.13333333333333333], ["total_activities", "active_maintainer_records", 0.4878048780487805], ["total_activities", "active_maintainers", 0.5882352941176471], ["total_activities", "approved_pull_requests", 0.2631578947368421], ["total_activities", "avg_project_health_score", 0.3], ["total_activities", "bot_activities", 0.8666666666666667], ["total_activities", "certification_enrollments", 0.3902439024390244], ["total_activities", "churned_membership_count", 0.05], ["total_activities", "churned_membership_discount_amount", 0.16], ["total_activities", "churned_membership_invoice_amount", 0.16326530612244897], ["total_activities", "code_contribution_activities", 0.5909090909090909], ["total_activities", "current_membership_count", 0.15], ["total_activities", "current_membership_discount_amount", 0.24], ["total_activities", "current_membership_invoice_amount", 0.20408163265306123], ["total_activities", "current_membership_revenue", 0.23809523809523808], ["total_activities", "current_new_account_membership_count", 0.2692307692307692], ["total_activities", "human_activities", 0.75], ["total_activities", "last_completed_year_active_discount_amount", 0.41379310344827586], ["total_activities", "last_completed_year_active_invoice_amount", 0.42105263157894735], ["total_activities", "last_completed_year_active_membership_count", 0.4067796610169492], ["total_activities", "last_completed_year_active_membership_revenue", 0.39344262295081966], ["total_activities", "lf_project_activities", 0.6486486486486487], ["total_activities", "main_branch_commits", 0.22857142857142856], ["total_activities", "membership_revenue", 0.17647058823529413], ["total_activities", "past_event_speakers", 0.2857142857142857], ["total_activities", "past_events_count", 0.18181818181818182], ["total_activities", "project_count", 0.27586206896551724], ["total_activities", "project_health_count", 0.3333333333333333], ["total_activities", "renewal_price", 0.3448275862068966], ["total_activities", "sponsorship_quantity_total", 0.23809523809523808], ["total_activities", "total_accepted_proposals", 0.55], ["total_activities", "total_activities", 1.0], ["total_activities", "total_certifications", 0.7222222222222222], ["total_activities", "total_code_deletions", 0.5555555555555556], ["total_activities", "total_code_insertions", 0.5405405405405406], ["total_activities", "total_contributing_organizations", 0.5416666666666666], ["total_activities", "total_contributors", 0.6470588235294118], ["total_activities", "total_discount_amount", 0.43243243243243246], ["total_activities", "total_downgrade_churn_amount", 0.4090909090909091], ["total_activities", "total_enrolled_users", 0.4444444444444444], ["total_activities", "total_enrollments", 0.48484848484848486], ["total_activities", "total_estimated_cost", 0.6111111111111112], ["total_activities", "total_event_registrations_goal", 0.43478260869565216], ["total_activities", "total_events", 0.5714285714285714], ["total_activities", "total_first_time_contributors", 0.4888888888888889], ["total_activities", "total_gross_revenue", 0.45714285714285713], ["total_activities", "total_invoice_amount", 0.4444444444444444], ["total_activities", "total_maintainer_records", 0.45], ["total_activities", "total_maintainers", 0.6666666666666666], ["total_activities", "total_next_membership_revenue", 0.4444444444444444], ["total_activities", "total_registration_net_revenue", 0.4782608695652174], ["total_activities", "total_registration_tax", 0.5263157894736842], ["total_activities", "total_registrations", 0.5714285714285714], ["total_activities", "total_software_value", 0.5], ["total_activities", "total_speakers", 0.6], ["total_activities", "total_speaking_engagements", 0.42857142857142855], ["total_activities", "total_sponsorship_count", 0.41025641025641024], ["total_activities", "total_sponsorship_revenue", 0.43902439024390244], ["total_activities", "training_enrollments", 0.2222222222222222], ["total_activities", "upcoming_events_count", 0.16216216216216217], ["total_certifications", "active_maintainer_records", 0.35555555555555557], ["total_certifications", "active_maintainers", 0.3157894736842105], ["total_certifications", "approved_pull_requests", 0.2857142857142857], ["total_certifications", "avg_project_health_score", 0.2727272727272727], ["total_certifications", "bot_activities", 0.5882352941176471], ["total_certifications", "certification_enrollments", 0.6222222222222222], ["total_certifications", "churned_membership_count", 0.13636363636363635], ["total_certifications", "churned_membership_discount_amount", 0.2222222222222222], ["total_certifications", "churned_membership_invoice_amount", 0.1509433962264151], ["total_certifications", "code_contribution_activities", 0.4166666666666667], ["total_certifications", "current_membership_count", 0.18181818181818182], ["total_certifications", "current_membership_discount_amount", 0.25925925925925924], ["total_certifications", "current_membership_invoice_amount", 0.18867924528301888], ["total_certifications", "current_membership_revenue", 0.2608695652173913], ["total_certifications", "current_new_account_membership_count", 0.21428571428571427], ["total_certifications", "human_activities", 0.5], ["total_certifications", "last_completed_year_active_discount_amount", 0.3548387096774194], ["total_certifications", "last_completed_year_active_invoice_amount", 0.36065573770491804], ["total_certifications", "last_completed_year_active_membership_count", 0.19047619047619047], ["total_certifications", "last_completed_year_active_membership_revenue", 0.2153846153846154], ["total_certifications", "lf_project_activities", 0.43902439024390244], ["total_certifications", "main_branch_commits", 0.2564102564102564], ["total_certifications", "membership_revenue", 0.21052631578947367], ["total_certifications", "past_event_speakers", 0.3076923076923077], ["total_certifications", "past_events_count", 0.2702702702702703], ["total_certifications", "project_count", 0.24242424242424243], ["total_certifications", "project_health_count", 0.3], ["total_certifications", "renewal_price", 0.30303030303030304], ["total_certifications", "sponsorship_quantity_total", 0.21739130434782608], ["total_certifications", "total_accepted_proposals", 0.5], ["total_certifications", "total_activities", 0.7222222222222222], ["total_certifications", "total_certifications", 1.0], ["total_certifications", "total_code_deletions", 0.65], ["total_certifications", "total_code_insertions", 0.6829268292682927], ["total_certifications", "total_contributing_organizations", 0.6538461538461539], ["total_certifications", "total_contributors", 0.5263157894736842], ["total_certifications", "total_discount_amount", 0.4878048780487805], ["total_certifications", "total_downgrade_churn_amount", 0.375], ["total_certifications", "total_enrolled_users", 0.45], ["total_certifications", "total_enrollments", 0.5405405405405406], ["total_certifications", "total_estimated_cost", 0.65], ["total_certifications", "total_event_registrations_goal", 0.6], ["total_certifications", "total_events", 0.5625], ["total_certifications", "total_first_time_contributors", 0.5306122448979592], ["total_certifications", "total_gross_revenue", 0.41025641025641024], ["total_certifications", "total_invoice_amount", 0.45], ["total_certifications", "total_maintainer_records", 0.5], ["total_certifications", "total_maintainers", 0.4864864864864865], ["total_certifications", "total_next_membership_revenue", 0.40816326530612246], ["total_certifications", "total_registration_net_revenue", 0.52], ["total_certifications", "total_registration_tax", 0.6190476190476191], ["total_certifications", "total_registrations", 0.717948717948718], ["total_certifications", "total_software_value", 0.4], ["total_certifications", "total_speakers", 0.5294117647058824], ["total_certifications", "total_speaking_engagements", 0.391304347826087], ["total_certifications", "total_sponsorship_count", 0.4186046511627907], ["total_certifications", "total_sponsorship_revenue", 0.4], ["total_certifications", "training_enrollments", 0.2], ["total_certifications", "upcoming_events_count", 0.1951219512195122], ["total_code_deletions", "active_maintainer_records", 0.35555555555555557], ["total_code_deletions", "active_maintainers", 0.2631578947368421], ["total_code_deletions", "approved_pull_requests", 0.3333333333333333], ["total_code_deletions", "avg_project_health_score", 0.3181818181818182], ["total_code_deletions", "bot_activities", 0.4117647058823529], ["total_code_deletions", "certification_enrollments", 0.3111111111111111], ["total_code_deletions", "churned_membership_count", 0.18181818181818182], ["total_code_deletions", "churned_membership_discount_amount", 0.18518518518518517], ["total_code_deletions", "churned_membership_invoice_amount", 0.18867924528301888], ["total_code_deletions", "code_contribution_activities", 0.4166666666666667], ["total_code_deletions", "current_membership_count", 0.22727272727272727], ["total_code_deletions", "current_membership_discount_amount", 0.2222222222222222], ["total_code_deletions", "current_membership_invoice_amount", 0.22641509433962265], ["total_code_deletions", "current_membership_revenue", 0.30434782608695654], ["total_code_deletions", "current_new_account_membership_count", 0.25], ["total_code_deletions", "human_activities", 0.3333333333333333], ["total_code_deletions", "last_completed_year_active_discount_amount", 0.3548387096774194], ["total_code_deletions", "last_completed_year_active_invoice_amount", 0.32786885245901637], ["total_code_deletions", "last_completed_year_active_membership_count", 0.31746031746031744], ["total_code_deletions", "last_completed_year_active_membership_revenue", 0.27692307692307694], ["total_code_deletions", "lf_project_activities", 0.2926829268292683], ["total_code_deletions", "main_branch_commits", 0.3076923076923077], ["total_code_deletions", "membership_revenue", 0.21052631578947367], ["total_code_deletions", "past_event_speakers", 0.2564102564102564], ["total_code_deletions", "past_events_count", 0.32432432432432434], ["total_code_deletions", "project_count", 0.30303030303030304], ["total_code_deletions", "project_health_count", 0.35], ["total_code_deletions", "renewal_price", 0.30303030303030304], ["total_code_deletions", "sponsorship_quantity_total", 0.21739130434782608], ["total_code_deletions", "total_accepted_proposals", 0.45454545454545453], ["total_code_deletions", "total_activities", 0.5555555555555556], ["total_code_deletions", "total_certifications", 0.65], ["total_code_deletions", "total_code_deletions", 1.0], ["total_code_deletions", "total_code_insertions", 0.8292682926829268], ["total_code_deletions", "total_contributing_organizations", 0.5384615384615384], ["total_code_deletions", "total_contributors", 0.631578947368421], ["total_code_deletions", "total_discount_amount", 0.4878048780487805], ["total_code_deletions", "total_downgrade_churn_amount", 0.4583333333333333], ["total_code_deletions", "total_enrolled_users", 0.5], ["total_code_deletions", "total_enrollments", 0.5405405405405406], ["total_code_deletions", "total_estimated_cost", 0.45], ["total_code_deletions", "total_event_registrations_goal", 0.56], ["total_code_deletions", "total_events", 0.625], ["total_code_deletions", "total_first_time_contributors", 0.4897959183673469], ["total_code_deletions", "total_gross_revenue", 0.5128205128205128], ["total_code_deletions", "total_invoice_amount", 0.5], ["total_code_deletions", "total_maintainer_records", 0.45454545454545453], ["total_code_deletions", "total_maintainers", 0.43243243243243246], ["total_code_deletions", "total_next_membership_revenue", 0.4897959183673469], ["total_code_deletions", "total_registration_net_revenue", 0.44], ["total_code_deletions", "total_registration_tax", 0.5238095238095238], ["total_code_deletions", "total_registrations", 0.6153846153846154], ["total_code_deletions", "total_software_value", 0.5], ["total_code_deletions", "total_speakers", 0.5294117647058824], ["total_code_deletions", "total_speaking_engagements", 0.5217391304347826], ["total_code_deletions", "total_sponsorship_count", 0.4186046511627907], ["total_code_deletions", "total_sponsorship_revenue", 0.4], ["total_code_deletions", "training_enrollments", 0.2], ["total_code_deletions", "upcoming_events_count", 0.24390243902439024], ["total_code_insertions", "active_maintainer_records", 0.34782608695652173], ["total_code_insertions", "active_maintainers", 0.41025641025641024], ["total_code_insertions", "approved_pull_requests", 0.32558139534883723], ["total_code_insertions", "avg_project_health_score", 0.3111111111111111], ["total_code_insertions", "bot_activities", 0.4], ["total_code_insertions", "certification_enrollments", 0.34782608695652173], ["total_code_insertions", "churned_membership_count", 0.2222222222222222], ["total_code_insertions", "churned_membership_discount_amount", 0.21818181818181817], ["total_code_insertions", "churned_membership_invoice_amount", 0.25925925925925924], ["total_code_insertions", "code_contribution_activities", 0.4489795918367347], ["total_code_insertions", "current_membership_count", 0.26666666666666666], ["total_code_insertions", "current_membership_discount_amount", 0.2545454545454545], ["total_code_insertions", "current_membership_invoice_amount", 0.2962962962962963], ["total_code_insertions", "current_membership_revenue", 0.2978723404255319], ["total_code_insertions", "current_new_account_membership_count", 0.2807017543859649], ["total_code_insertions", "human_activities", 0.32432432432432434], ["total_code_insertions", "last_completed_year_active_discount_amount", 0.38095238095238093], ["total_code_insertions", "last_completed_year_active_invoice_amount", 0.3548387096774194], ["total_code_insertions", "last_completed_year_active_membership_count", 0.3125], ["total_code_insertions", "last_completed_year_active_membership_revenue", 0.3333333333333333], ["total_code_insertions", "lf_project_activities", 0.2857142857142857], ["total_code_insertions", "main_branch_commits", 0.3], ["total_code_insertions", "membership_revenue", 0.2564102564102564], ["total_code_insertions", "past_event_speakers", 0.3], ["total_code_insertions", "past_events_count", 0.3684210526315789], ["total_code_insertions", "project_count", 0.35294117647058826], ["total_code_insertions", "project_health_count", 0.3902439024390244], ["total_code_insertions", "renewal_price", 0.29411764705882354], ["total_code_insertions", "sponsorship_quantity_total", 0.2127659574468085], ["total_code_insertions", "total_accepted_proposals", 0.4444444444444444], ["total_code_insertions", "total_activities", 0.5405405405405406], ["total_code_insertions", "total_certifications", 0.6829268292682927], ["total_code_insertions", "total_code_deletions", 0.8292682926829268], ["total_code_insertions", "total_code_insertions", 1.0], ["total_code_insertions", "total_contributing_organizations", 0.6037735849056604], ["total_code_insertions", "total_contributors", 0.5128205128205128], ["total_code_insertions", "total_discount_amount", 0.5238095238095238], ["total_code_insertions", "total_downgrade_churn_amount", 0.4897959183673469], ["total_code_insertions", "total_enrolled_users", 0.6341463414634146], ["total_code_insertions", "total_enrollments", 0.5263157894736842], ["total_code_insertions", "total_estimated_cost", 0.4878048780487805], ["total_code_insertions", "total_event_registrations_goal", 0.6274509803921569], ["total_code_insertions", "total_events", 0.5454545454545454], ["total_code_insertions", "total_first_time_contributors", 0.4], ["total_code_insertions", "total_gross_revenue", 0.5], ["total_code_insertions", "total_invoice_amount", 0.5365853658536586], ["total_code_insertions", "total_maintainer_records", 0.4444444444444444], ["total_code_insertions", "total_maintainers", 0.5789473684210527], ["total_code_insertions", "total_next_membership_revenue", 0.48], ["total_code_insertions", "total_registration_net_revenue", 0.5490196078431373], ["total_code_insertions", "total_registration_tax", 0.6511627906976745], ["total_code_insertions", "total_registrations", 0.75], ["total_code_insertions", "total_software_value", 0.4878048780487805], ["total_code_insertions", "total_speakers", 0.5714285714285714], ["total_code_insertions", "total_speaking_engagements", 0.425531914893617], ["total_code_insertions", "total_sponsorship_count", 0.45454545454545453], ["total_code_insertions", "total_sponsorship_revenue", 0.43478260869565216], ["total_code_insertions", "training_enrollments", 0.24390243902439024], ["total_code_insertions", "upcoming_events_count", 0.2857142857142857], ["total_contributing_organizations", "active_maintainer_records", 0.2807017543859649], ["total_contributing_organizations", "active_maintainers", 0.28], ["total_contributing_organizations", "approved_pull_requests", 0.18518518518518517], ["total_contributing_organizations", "avg_project_health_score", 0.25], ["total_contributing_organizations", "bot_activities", 0.43478260869565216], ["total_contributing_organizations", "certification_enrollments", 0.38596491228070173], ["total_contributing_organizations", "churned_membership_count", 0.17857142857142858], ["total_contributing_organizations", "churned_membership_discount_amount", 0.21212121212121213], ["total_contributing_organizations", "churned_membership_invoice_amount", 0.15384615384615385], ["total_contributing_organizations", "code_contribution_activities", 0.6], ["total_contributing_organizations", "current_membership_count", 0.21428571428571427], ["total_contributing_organizations", "current_membership_discount_amount", 0.24242424242424243], ["total_contributing_organizations", "current_membership_invoice_amount", 0.3384615384615385], ["total_contributing_organizations", "current_membership_revenue", 0.20689655172413793], ["total_contributing_organizations", "current_new_account_membership_count", 0.23529411764705882], ["total_contributing_organizations", "human_activities", 0.375], ["total_contributing_organizations", "last_completed_year_active_discount_amount", 0.21621621621621623], ["total_contributing_organizations", "last_completed_year_active_invoice_amount", 0.1643835616438356], ["total_contributing_organizations", "last_completed_year_active_membership_count", 0.16], ["total_contributing_organizations", "last_completed_year_active_membership_revenue", 0.23376623376623376], ["total_contributing_organizations", "lf_project_activities", 0.33962264150943394], ["total_contributing_organizations", "main_branch_commits", 0.23529411764705882], ["total_contributing_organizations", "membership_revenue", 0.12], ["total_contributing_organizations", "past_event_speakers", 0.23529411764705882], ["total_contributing_organizations", "past_events_count", 0.2857142857142857], ["total_contributing_organizations", "project_count", 0.26666666666666666], ["total_contributing_organizations", "project_health_count", 0.3076923076923077], ["total_contributing_organizations", "renewal_price", 0.2222222222222222], ["total_contributing_organizations", "sponsorship_quantity_total", 0.1724137931034483], ["total_contributing_organizations", "total_accepted_proposals", 0.39285714285714285], ["total_contributing_organizations", "total_activities", 0.5416666666666666], ["total_contributing_organizations", "total_certifications", 0.6538461538461539], ["total_contributing_organizations", "total_code_deletions", 0.5384615384615384], ["total_contributing_organizations", "total_code_insertions", 0.6037735849056604], ["total_contributing_organizations", "total_contributing_organizations", 1.0], ["total_contributing_organizations", "total_contributors", 0.72], ["total_contributing_organizations", "total_discount_amount", 0.4528301886792453], ["total_contributing_organizations", "total_downgrade_churn_amount", 0.3333333333333333], ["total_contributing_organizations", "total_enrolled_users", 0.34615384615384615], ["total_contributing_organizations", "total_enrollments", 0.40816326530612246], ["total_contributing_organizations", "total_estimated_cost", 0.34615384615384615], ["total_contributing_organizations", "total_event_registrations_goal", 0.5806451612903226], ["total_contributing_organizations", "total_events", 0.4090909090909091], ["total_contributing_organizations", "total_first_time_contributors", 0.5901639344262295], ["total_contributing_organizations", "total_gross_revenue", 0.35294117647058826], ["total_contributing_organizations", "total_invoice_amount", 0.38461538461538464], ["total_contributing_organizations", "total_maintainer_records", 0.35714285714285715], ["total_contributing_organizations", "total_maintainers", 0.4897959183673469], ["total_contributing_organizations", "total_next_membership_revenue", 0.36065573770491804], ["total_contributing_organizations", "total_registration_net_revenue", 0.41935483870967744], ["total_contributing_organizations", "total_registration_tax", 0.48148148148148145], ["total_contributing_organizations", "total_registrations", 0.5490196078431373], ["total_contributing_organizations", "total_software_value", 0.38461538461538464], ["total_contributing_organizations", "total_speakers", 0.34782608695652173], ["total_contributing_organizations", "total_speaking_engagements", 0.5172413793103449], ["total_contributing_organizations", "total_sponsorship_count", 0.32727272727272727], ["total_contributing_organizations", "total_sponsorship_revenue", 0.3157894736842105], ["total_contributing_organizations", "training_enrollments", 0.4230769230769231], ["total_contributing_organizations", "upcoming_events_count", 0.33962264150943394], ["total_contributors", "active_maintainer_records", 0.37209302325581395], ["total_contributors", "active_maintainers", 0.3333333333333333], ["total_contributors", "approved_pull_requests", 0.25], ["total_contributors", "avg_project_health_score", 0.3333333333333333], ["total_contributors", "bot_activities", 0.5], ["total_contributors", "certification_enrollments", 0.27906976744186046], ["total_contributors", "churned_membership_count", 0.23809523809523808], ["total_contributors", "churned_membership_discount_amount", 0.2692307692307692], ["total_contributors", "churned_membership_invoice_amount", 0.19607843137254902], ["total_contributors", "code_contribution_activities", 0.5652173913043478], ["total_contributors", "current_membership_count", 0.2857142857142857], ["total_contributors", "current_membership_discount_amount", 0.3076923076923077], ["total_contributors", "current_membership_invoice_amount", 0.23529411764705882], ["total_contributors", "current_membership_revenue", 0.2727272727272727], ["total_contributors", "current_new_account_membership_count", 0.2962962962962963], ["total_contributors", "human_activities", 0.23529411764705882], ["total_contributors", "last_completed_year_active_discount_amount", 0.26666666666666666], ["total_contributors", "last_completed_year_active_invoice_amount", 0.2033898305084746], ["total_contributors", "last_completed_year_active_membership_count", 0.19672131147540983], ["total_contributors", "last_completed_year_active_membership_revenue", 0.31746031746031744], ["total_contributors", "lf_project_activities", 0.2564102564102564], ["total_contributors", "main_branch_commits", 0.32432432432432434], ["total_contributors", "membership_revenue", 0.16666666666666666], ["total_contributors", "past_event_speakers", 0.32432432432432434], ["total_contributors", "past_events_count", 0.4], ["total_contributors", "project_count", 0.3870967741935484], ["total_contributors", "project_health_count", 0.42105263157894735], ["total_contributors", "renewal_price", 0.3225806451612903], ["total_contributors", "sponsorship_quantity_total", 0.22727272727272727], ["total_contributors", "total_accepted_proposals", 0.47619047619047616], ["total_contributors", "total_activities", 0.6470588235294118], ["total_contributors", "total_certifications", 0.5263157894736842], ["total_contributors", "total_code_deletions", 0.5263157894736842], ["total_contributors", "total_code_insertions", 0.6666666666666666], ["total_contributors", "total_contributing_organizations", 0.72], ["total_contributors", "total_contributors", 1.0], ["total_contributors", "total_discount_amount", 0.6153846153846154], ["total_contributors", "total_downgrade_churn_amount", 0.43478260869565216], ["total_contributors", "total_enrolled_users", 0.5263157894736842], ["total_contributors", "total_enrollments", 0.5714285714285714], ["total_contributors", "total_estimated_cost", 0.47368421052631576], ["total_contributors", "total_event_registrations_goal", 0.375], ["total_contributors", "total_events", 0.6], ["total_contributors", "total_first_time_contributors", 0.7659574468085106], ["total_contributors", "total_gross_revenue", 0.4864864864864865], ["total_contributors", "total_invoice_amount", 0.5263157894736842], ["total_contributors", "total_maintainer_records", 0.47619047619047616], ["total_contributors", "total_maintainers", 0.6285714285714286], ["total_contributors", "total_next_membership_revenue", 0.46808510638297873], ["total_contributors", "total_registration_net_revenue", 0.4583333333333333], ["total_contributors", "total_registration_tax", 0.45], ["total_contributors", "total_registrations", 0.4864864864864865], ["total_contributors", "total_software_value", 0.5263157894736842], ["total_contributors", "total_speakers", 0.5], ["total_contributors", "total_speaking_engagements", 0.4090909090909091], ["total_contributors", "total_sponsorship_count", 0.5365853658536586], ["total_contributors", "total_sponsorship_revenue", 0.5116279069767442], ["total_contributors", "training_enrollments", 0.3157894736842105], ["total_contributors", "upcoming_events_count", 0.3076923076923077], ["total_discount_amount", "active_maintainer_records", 0.2608695652173913], ["total_discount_amount", "active_maintainers", 0.2564102564102564], ["total_discount_amount", "approved_pull_requests", 0.23255813953488372], ["total_discount_amount", "avg_project_health_score", 0.3111111111111111], ["total_discount_amount", "bot_activities", 0.2857142857142857], ["total_discount_amount", "certification_enrollments", 0.21739130434782608], ["total_discount_amount", "churned_membership_count", 0.3111111111111111], ["total_discount_amount", "churned_membership_discount_amount", 0.5818181818181818], ["total_discount_amount", "churned_membership_invoice_amount", 0.37037037037037035], ["total_discount_amount", "code_contribution_activities", 0.2857142857142857], ["total_discount_amount", "current_membership_count", 0.35555555555555557], ["total_discount_amount", "current_membership_discount_amount", 0.6181818181818182], ["total_discount_amount", "current_membership_invoice_amount", 0.4074074074074074], ["total_discount_amount", "current_membership_revenue", 0.2978723404255319], ["total_discount_amount", "current_new_account_membership_count", 0.45614035087719296], ["total_discount_amount", "human_activities", 0.2702702702702703], ["total_discount_amount", "last_completed_year_active_discount_amount", 0.6349206349206349], ["total_discount_amount", "last_completed_year_active_invoice_amount", 0.3870967741935484], ["total_discount_amount", "last_completed_year_active_membership_count", 0.375], ["total_discount_amount", "last_completed_year_active_membership_revenue", 0.2727272727272727], ["total_discount_amount", "lf_project_activities", 0.2857142857142857], ["total_discount_amount", "main_branch_commits", 0.25], ["total_discount_amount", "membership_revenue", 0.10256410256410256], ["total_discount_amount", "past_event_speakers", 0.3], ["total_discount_amount", "past_events_count", 0.42105263157894735], ["total_discount_amount", "project_count", 0.4117647058823529], ["total_discount_amount", "project_health_count", 0.43902439024390244], ["total_discount_amount", "renewal_price", 0.29411764705882354], ["total_discount_amount", "sponsorship_quantity_total", 0.2127659574468085], ["total_discount_amount", "total_accepted_proposals", 0.4], ["total_discount_amount", "total_activities", 0.43243243243243246], ["total_discount_amount", "total_certifications", 0.3902439024390244], ["total_discount_amount", "total_code_deletions", 0.43902439024390244], ["total_discount_amount", "total_code_insertions", 0.5714285714285714], ["total_discount_amount", "total_contributing_organizations", 0.5283018867924528], ["total_discount_amount", "total_contributors", 0.5641025641025641], ["total_discount_amount", "total_discount_amount", 1.0], ["total_discount_amount", "total_downgrade_churn_amount", 0.6938775510204082], ["total_discount_amount", "total_enrolled_users", 0.3902439024390244], ["total_discount_amount", "total_enrollments", 0.47368421052631576], ["total_discount_amount", "total_estimated_cost", 0.4878048780487805], ["total_discount_amount", "total_event_registrations_goal", 0.47058823529411764], ["total_discount_amount", "total_events", 0.48484848484848486], ["total_discount_amount", "total_first_time_contributors", 0.52], ["total_discount_amount", "total_gross_revenue", 0.4], ["total_discount_amount", "total_invoice_amount", 0.7317073170731707], ["total_discount_amount", "total_maintainer_records", 0.4], ["total_discount_amount", "total_maintainers", 0.5789473684210527], ["total_discount_amount", "total_next_membership_revenue", 0.44], ["total_discount_amount", "total_registration_net_revenue", 0.5098039215686274], ["total_discount_amount", "total_registration_tax", 0.5581395348837209], ["total_discount_amount", "total_registrations", 0.5], ["total_discount_amount", "total_software_value", 0.43902439024390244], ["total_discount_amount", "total_speakers", 0.45714285714285713], ["total_discount_amount", "total_speaking_engagements", 0.3829787234042553], ["total_discount_amount", "total_sponsorship_count", 0.5454545454545454], ["total_discount_amount", "total_sponsorship_revenue", 0.34782608695652173], ["total_discount_amount", "training_enrollments", 0.24390243902439024], ["total_discount_amount", "upcoming_events_count", 0.3333333333333333], ["total_downgrade_churn_amount", "active_maintainer_records", 0.18867924528301888], ["total_downgrade_churn_amount", "active_maintainers", 0.21739130434782608], ["total_downgrade_churn_amount", "approved_pull_requests", 0.28], ["total_downgrade_churn_amount", "avg_project_health_score", 0.2692307692307692], ["total_downgrade_churn_amount", "bot_activities", 0.23809523809523808], ["total_downgrade_churn_amount", "certification_enrollments", 0.2641509433962264], ["total_downgrade_churn_amount", "churned_membership_count", 0.4230769230769231], ["total_downgrade_churn_amount", "churned_membership_discount_amount", 0.3870967741935484], ["total_downgrade_churn_amount", "churned_membership_invoice_amount", 0.39344262295081966], ["total_downgrade_churn_amount", "code_contribution_activities", 0.35714285714285715], ["total_downgrade_churn_amount", "current_membership_count", 0.34615384615384615], ["total_downgrade_churn_amount", "current_membership_discount_amount", 0.3870967741935484], ["total_downgrade_churn_amount", "current_membership_invoice_amount", 0.36065573770491804], ["total_downgrade_churn_amount", "current_membership_revenue", 0.18518518518518517], ["total_downgrade_churn_amount", "current_new_account_membership_count", 0.3125], ["total_downgrade_churn_amount", "human_activities", 0.2727272727272727], ["total_downgrade_churn_amount", "last_completed_year_active_discount_amount", 0.42857142857142855], ["total_downgrade_churn_amount", "last_completed_year_active_invoice_amount", 0.463768115942029], ["total_downgrade_churn_amount", "last_completed_year_active_membership_count", 0.36619718309859156], ["total_downgrade_churn_amount", "last_completed_year_active_membership_revenue", 0.273972602739726], ["total_downgrade_churn_amount", "lf_project_activities", 0.16326530612244897], ["total_downgrade_churn_amount", "main_branch_commits", 0.3404255319148936], ["total_downgrade_churn_amount", "membership_revenue", 0.13043478260869565], ["total_downgrade_churn_amount", "past_event_speakers", 0.2127659574468085], ["total_downgrade_churn_amount", "past_events_count", 0.35555555555555557], ["total_downgrade_churn_amount", "project_count", 0.34146341463414637], ["total_downgrade_churn_amount", "project_health_count", 0.375], ["total_downgrade_churn_amount", "renewal_price", 0.24390243902439024], ["total_downgrade_churn_amount", "sponsorship_quantity_total", 0.18518518518518517], ["total_downgrade_churn_amount", "total_accepted_proposals", 0.34615384615384615], ["total_downgrade_churn_amount", "total_activities", 0.36363636363636365], ["total_downgrade_churn_amount", "total_certifications", 0.3333333333333333], ["total_downgrade_churn_amount", "total_code_deletions", 0.4583333333333333], ["total_downgrade_churn_amount", "total_code_insertions", 0.4897959183673469], ["total_downgrade_churn_amount", "total_contributing_organizations", 0.5], ["total_downgrade_churn_amount", "total_contributors", 0.391304347826087], ["total_downgrade_churn_amount", "total_discount_amount", 0.6530612244897959], ["total_downgrade_churn_amount", "total_downgrade_churn_amount", 1.0], ["total_downgrade_churn_amount", "total_enrolled_users", 0.3333333333333333], ["total_downgrade_churn_amount", "total_enrollments", 0.4444444444444444], ["total_downgrade_churn_amount", "total_estimated_cost", 0.4583333333333333], ["total_downgrade_churn_amount", "total_event_registrations_goal", 0.41379310344827586], ["total_downgrade_churn_amount", "total_events", 0.45], ["total_downgrade_churn_amount", "total_first_time_contributors", 0.45614035087719296], ["total_downgrade_churn_amount", "total_gross_revenue", 0.425531914893617], ["total_downgrade_churn_amount", "total_invoice_amount", 0.625], ["total_downgrade_churn_amount", "total_maintainer_records", 0.34615384615384615], ["total_downgrade_churn_amount", "total_maintainers", 0.4], ["total_downgrade_churn_amount", "total_next_membership_revenue", 0.3508771929824561], ["total_downgrade_churn_amount", "total_registration_net_revenue", 0.41379310344827586], ["total_downgrade_churn_amount", "total_registration_tax", 0.48], ["total_downgrade_churn_amount", "total_registrations", 0.425531914893617], ["total_downgrade_churn_amount", "total_software_value", 0.5], ["total_downgrade_churn_amount", "total_speakers", 0.3333333333333333], ["total_downgrade_churn_amount", "total_speaking_engagements", 0.48148148148148145], ["total_downgrade_churn_amount", "total_sponsorship_count", 0.5882352941176471], ["total_downgrade_churn_amount", "total_sponsorship_revenue", 0.41509433962264153], ["total_downgrade_churn_amount", "training_enrollments", 0.3333333333333333], ["total_downgrade_churn_amount", "upcoming_events_count", 0.40816326530612246], ["total_enrolled_users", "active_maintainer_records", 0.3111111111111111], ["total_enrolled_users", "active_maintainers", 0.3684210526315789], ["total_enrolled_users", "approved_pull_requests", 0.42857142857142855], ["total_enrolled_users", "avg_project_health_score", 0.22727272727272727], ["total_enrolled_users", "bot_activities", 0.29411764705882354], ["total_enrolled_users", "certification_enrollments", 0.4888888888888889], ["total_enrolled_users", "churned_membership_count", 0.3181818181818182], ["total_enrolled_users", "churned_membership_discount_amount", 0.25925925925925924], ["total_enrolled_users", "churned_membership_invoice_amount", 0.2641509433962264], ["total_enrolled_users", "code_contribution_activities", 0.20833333333333334], ["total_enrolled_users", "current_membership_count", 0.2727272727272727], ["total_enrolled_users", "current_membership_discount_amount", 0.2222222222222222], ["total_enrolled_users", "current_membership_invoice_amount", 0.22641509433962265], ["total_enrolled_users", "current_membership_revenue", 0.2608695652173913], ["total_enrolled_users", "current_new_account_membership_count", 0.25], ["total_enrolled_users", "human_activities", 0.2222222222222222], ["total_enrolled_users", "last_completed_year_active_discount_amount", 0.22580645161290322], ["total_enrolled_users", "last_completed_year_active_invoice_amount", 0.22950819672131148], ["total_enrolled_users", "last_completed_year_active_membership_count", 0.2857142857142857], ["total_enrolled_users", "last_completed_year_active_membership_revenue", 0.27692307692307694], ["total_enrolled_users", "lf_project_activities", 0.34146341463414637], ["total_enrolled_users", "main_branch_commits", 0.10256410256410256], ["total_enrolled_users", "membership_revenue", 0.21052631578947367], ["total_enrolled_users", "past_event_speakers", 0.41025641025641024], ["total_enrolled_users", "past_events_count", 0.32432432432432434], ["total_enrolled_users", "project_count", 0.30303030303030304], ["total_enrolled_users", "project_health_count", 0.25], ["total_enrolled_users", "renewal_price", 0.24242424242424243], ["total_enrolled_users", "sponsorship_quantity_total", 0.21739130434782608], ["total_enrolled_users", "total_accepted_proposals", 0.5454545454545454], ["total_enrolled_users", "total_activities", 0.4444444444444444], ["total_enrolled_users", "total_certifications", 0.45], ["total_enrolled_users", "total_code_deletions", 0.5], ["total_enrolled_users", "total_code_insertions", 0.5853658536585366], ["total_enrolled_users", "total_contributing_organizations", 0.38461538461538464], ["total_enrolled_users", "total_contributors", 0.5789473684210527], ["total_enrolled_users", "total_discount_amount", 0.43902439024390244], ["total_enrolled_users", "total_downgrade_churn_amount", 0.4166666666666667], ["total_enrolled_users", "total_enrolled_users", 1.0], ["total_enrolled_users", "total_enrollments", 0.7567567567567568], ["total_enrolled_users", "total_estimated_cost", 0.55], ["total_enrolled_users", "total_event_registrations_goal", 0.44], ["total_enrolled_users", "total_events", 0.5625], ["total_enrolled_users", "total_first_time_contributors", 0.32653061224489793], ["total_enrolled_users", "total_gross_revenue", 0.46153846153846156], ["total_enrolled_users", "total_invoice_amount", 0.4], ["total_enrolled_users", "total_maintainer_records", 0.45454545454545453], ["total_enrolled_users", "total_maintainers", 0.5405405405405406], ["total_enrolled_users", "total_next_membership_revenue", 0.4489795918367347], ["total_enrolled_users", "total_registration_net_revenue", 0.36], ["total_enrolled_users", "total_registration_tax", 0.42857142857142855], ["total_enrolled_users", "total_registrations", 0.46153846153846156], ["total_enrolled_users", "total_software_value", 0.45], ["total_enrolled_users", "total_speakers", 0.5882352941176471], ["total_enrolled_users", "total_speaking_engagements", 0.43478260869565216], ["total_enrolled_users", "total_sponsorship_count", 0.46511627906976744], ["total_enrolled_users", "total_sponsorship_revenue", 0.4], ["total_enrolled_users", "training_enrollments", 0.55], ["total_enrolled_users", "upcoming_events_count", 0.2926829268292683], ["total_enrollments", "active_maintainer_records", 0.3333333333333333], ["total_enrollments", "active_maintainers", 0.34285714285714286], ["total_enrollments", "approved_pull_requests", 0.358974358974359], ["total_enrollments", "avg_project_health_score", 0.24390243902439024], ["total_enrollments", "bot_activities", 0.3225806451612903], ["total_enrollments", "certification_enrollments", 0.6666666666666666], ["total_enrollments", "churned_membership_count", 0.24390243902439024], ["total_enrollments", "churned_membership_discount_amount", 0.19607843137254902], ["total_enrollments", "churned_membership_invoice_amount", 0.2], ["total_enrollments", "code_contribution_activities", 0.26666666666666666], ["total_enrollments", "current_membership_count", 0.24390243902439024], ["total_enrollments", "current_membership_discount_amount", 0.19607843137254902], ["total_enrollments", "current_membership_invoice_amount", 0.2], ["total_enrollments", "current_membership_revenue", 0.23255813953488372], ["total_enrollments", "current_new_account_membership_count", 0.18867924528301888], ["total_enrollments", "human_activities", 0.24242424242424243], ["total_enrollments", "last_completed_year_active_discount_amount", 0.3050847457627119], ["total_enrollments", "last_completed_year_active_invoice_amount", 0.3793103448275862], ["total_enrollments", "last_completed_year_active_membership_count", 0.3333333333333333], ["total_enrollments", "last_completed_year_active_membership_revenue", 0.25806451612903225], ["total_enrollments", "lf_project_activities", 0.3684210526315789], ["total_enrollments", "main_branch_commits", 0.2222222222222222], ["total_enrollments", "membership_revenue", 0.22857142857142856], ["total_enrollments", "past_event_speakers", 0.3888888888888889], ["total_enrollments", "past_events_count", 0.4117647058823529], ["total_enrollments", "project_count", 0.3333333333333333], ["total_enrollments", "project_health_count", 0.3783783783783784], ["total_enrollments", "renewal_price", 0.26666666666666666], ["total_enrollments", "sponsorship_quantity_total", 0.23255813953488372], ["total_enrollments", "total_accepted_proposals", 0.5365853658536586], ["total_enrollments", "total_activities", 0.48484848484848486], ["total_enrollments", "total_certifications", 0.4864864864864865], ["total_enrollments", "total_code_deletions", 0.4864864864864865], ["total_enrollments", "total_code_insertions", 0.631578947368421], ["total_enrollments", "total_contributing_organizations", 0.40816326530612246], ["total_enrollments", "total_contributors", 0.5714285714285714], ["total_enrollments", "total_discount_amount", 0.47368421052631576], ["total_enrollments", "total_downgrade_churn_amount", 0.4888888888888889], ["total_enrollments", "total_enrolled_users", 0.7567567567567568], ["total_enrollments", "total_enrollments", 1.0], ["total_enrollments", "total_estimated_cost", 0.4864864864864865], ["total_enrollments", "total_event_registrations_goal", 0.46808510638297873], ["total_enrollments", "total_events", 0.7586206896551724], ["total_enrollments", "total_first_time_contributors", 0.5217391304347826], ["total_enrollments", "total_gross_revenue", 0.5], ["total_enrollments", "total_invoice_amount", 0.5405405405405406], ["total_enrollments", "total_maintainer_records", 0.4878048780487805], ["total_enrollments", "total_maintainers", 0.5882352941176471], ["total_enrollments", "total_next_membership_revenue", 0.391304347826087], ["total_enrollments", "total_registration_net_revenue", 0.3829787234042553], ["total_enrollments", "total_registration_tax", 0.46153846153846156], ["total_enrollments", "total_registrations", 0.5], ["total_enrollments", "total_software_value", 0.4864864864864865], ["total_enrollments", "total_speakers", 0.5806451612903226], ["total_enrollments", "total_speaking_engagements", 0.6046511627906976], ["total_enrollments", "total_sponsorship_count", 0.55], ["total_enrollments", "total_sponsorship_revenue", 0.42857142857142855], ["total_enrollments", "training_enrollments", 0.7567567567567568], ["total_enrollments", "upcoming_events_count", 0.3684210526315789], ["total_estimated_cost", "active_maintainer_records", 0.35555555555555557], ["total_estimated_cost", "active_maintainers", 0.2631578947368421], ["total_estimated_cost", "approved_pull_requests", 0.3333333333333333], ["total_estimated_cost", "avg_project_health_score", 0.3181818181818182], ["total_estimated_cost", "bot_activities", 0.29411764705882354], ["total_estimated_cost", "certification_enrollments", 0.2222222222222222], ["total_estimated_cost", "churned_membership_count", 0.2727272727272727], ["total_estimated_cost", "churned_membership_discount_amount", 0.2222222222222222], ["total_estimated_cost", "churned_membership_invoice_amount", 0.22641509433962265], ["total_estimated_cost", "code_contribution_activities", 0.25], ["total_estimated_cost", "current_membership_count", 0.4090909090909091], ["total_estimated_cost", "current_membership_discount_amount", 0.3333333333333333], ["total_estimated_cost", "current_membership_invoice_amount", 0.11320754716981132], ["total_estimated_cost", "current_membership_revenue", 0.2608695652173913], ["total_estimated_cost", "current_new_account_membership_count", 0.39285714285714285], ["total_estimated_cost", "human_activities", 0.2222222222222222], ["total_estimated_cost", "last_completed_year_active_discount_amount", 0.3548387096774194], ["total_estimated_cost", "last_completed_year_active_invoice_amount", 0.36065573770491804], ["total_estimated_cost", "last_completed_year_active_membership_count", 0.3492063492063492], ["total_estimated_cost", "last_completed_year_active_membership_revenue", 0.3076923076923077], ["total_estimated_cost", "lf_project_activities", 0.1951219512195122], ["total_estimated_cost", "main_branch_commits", 0.3076923076923077], ["total_estimated_cost", "membership_revenue", 0.15789473684210525], ["total_estimated_cost", "past_event_speakers", 0.358974358974359], ["total_estimated_cost", "past_events_count", 0.43243243243243246], ["total_estimated_cost", "project_count", 0.30303030303030304], ["total_estimated_cost", "project_health_count", 0.4], ["total_estimated_cost", "renewal_price", 0.24242424242424243], ["total_estimated_cost", "sponsorship_quantity_total", 0.21739130434782608], ["total_estimated_cost", "total_accepted_proposals", 0.5909090909090909], ["total_estimated_cost", "total_activities", 0.4444444444444444], ["total_estimated_cost", "total_certifications", 0.65], ["total_estimated_cost", "total_code_deletions", 0.55], ["total_estimated_cost", "total_code_insertions", 0.5853658536585366], ["total_estimated_cost", "total_contributing_organizations", 0.46153846153846156], ["total_estimated_cost", "total_contributors", 0.47368421052631576], ["total_estimated_cost", "total_discount_amount", 0.4878048780487805], ["total_estimated_cost", "total_downgrade_churn_amount", 0.4583333333333333], ["total_estimated_cost", "total_enrolled_users", 0.55], ["total_estimated_cost", "total_enrollments", 0.43243243243243246], ["total_estimated_cost", "total_estimated_cost", 1.0], ["total_estimated_cost", "total_event_registrations_goal", 0.52], ["total_estimated_cost", "total_events", 0.5], ["total_estimated_cost", "total_first_time_contributors", 0.6122448979591837], ["total_estimated_cost", "total_gross_revenue", 0.41025641025641024], ["total_estimated_cost", "total_invoice_amount", 0.4], ["total_estimated_cost", "total_maintainer_records", 0.6363636363636364], ["total_estimated_cost", "total_maintainers", 0.5945945945945946], ["total_estimated_cost", "total_next_membership_revenue", 0.40816326530612246], ["total_estimated_cost", "total_registration_net_revenue", 0.52], ["total_estimated_cost", "total_registration_tax", 0.6190476190476191], ["total_estimated_cost", "total_registrations", 0.6666666666666666], ["total_estimated_cost", "total_software_value", 0.45], ["total_estimated_cost", "total_speakers", 0.47058823529411764], ["total_estimated_cost", "total_speaking_engagements", 0.34782608695652173], ["total_estimated_cost", "total_sponsorship_count", 0.5581395348837209], ["total_estimated_cost", "total_sponsorship_revenue", 0.35555555555555557], ["total_estimated_cost", "training_enrollments", 0.25], ["total_estimated_cost", "upcoming_events_count", 0.3902439024390244], ["total_event_registrations_goal", "active_maintainer_records", 0.2909090909090909], ["total_event_registrations_goal", "active_maintainers", 0.25], ["total_event_registrations_goal", "approved_pull_requests", 0.34615384615384615], ["total_event_registrations_goal", "avg_project_health_score", 0.2222222222222222], ["total_event_registrations_goal", "bot_activities", 0.2727272727272727], ["total_event_registrations_goal", "certification_enrollments", 0.2909090909090909], ["total_event_registrations_goal", "churned_membership_count", 0.1111111111111111], ["total_event_registrations_goal", "churned_membership_discount_amount", 0.15625], ["total_event_registrations_goal", "churned_membership_invoice_amount", 0.12698412698412698], ["total_event_registrations_goal", "code_contribution_activities", 0.3793103448275862], ["total_event_registrations_goal", "current_membership_count", 0.25925925925925924], ["total_event_registrations_goal", "current_membership_discount_amount", 0.3125], ["total_event_registrations_goal", "current_membership_invoice_amount", 0.2222222222222222], ["total_event_registrations_goal", "current_membership_revenue", 0.25], ["total_event_registrations_goal", "current_new_account_membership_count", 0.21212121212121213], ["total_event_registrations_goal", "human_activities", 0.2608695652173913], ["total_event_registrations_goal", "last_completed_year_active_discount_amount", 0.3055555555555556], ["total_event_registrations_goal", "last_completed_year_active_invoice_amount", 0.2535211267605634], ["total_event_registrations_goal", "last_completed_year_active_membership_count", 0.2465753424657534], ["total_event_registrations_goal", "last_completed_year_active_membership_revenue", 0.26666666666666666], ["total_event_registrations_goal", "lf_project_activities", 0.3137254901960784], ["total_event_registrations_goal", "main_branch_commits", 0.24489795918367346], ["total_event_registrations_goal", "membership_revenue", 0.25], ["total_event_registrations_goal", "past_event_speakers", 0.40816326530612246], ["total_event_registrations_goal", "past_events_count", 0.425531914893617], ["total_event_registrations_goal", "project_count", 0.18604651162790697], ["total_event_registrations_goal", "project_health_count", 0.24], ["total_event_registrations_goal", "renewal_price", 0.18604651162790697], ["total_event_registrations_goal", "sponsorship_quantity_total", 0.17857142857142858], ["total_event_registrations_goal", "total_accepted_proposals", 0.48148148148148145], ["total_event_registrations_goal", "total_activities", 0.43478260869565216], ["total_event_registrations_goal", "total_certifications", 0.6], ["total_event_registrations_goal", "total_code_deletions", 0.56], ["total_event_registrations_goal", "total_code_insertions", 0.5490196078431373], ["total_event_registrations_goal", "total_contributing_organizations", 0.5806451612903226], ["total_event_registrations_goal", "total_contributors", 0.4583333333333333], ["total_event_registrations_goal", "total_discount_amount", 0.39215686274509803], ["total_event_registrations_goal", "total_downgrade_churn_amount", 0.3103448275862069], ["total_event_registrations_goal", "total_enrolled_users", 0.44], ["total_event_registrations_goal", "total_enrollments", 0.46808510638297873], ["total_event_registrations_goal", "total_estimated_cost", 0.52], ["total_event_registrations_goal", "total_event_registrations_goal", 1.0], ["total_event_registrations_goal", "total_events", 0.5714285714285714], ["total_event_registrations_goal", "total_first_time_contributors", 0.4067796610169492], ["total_event_registrations_goal", "total_gross_revenue", 0.4489795918367347], ["total_event_registrations_goal", "total_invoice_amount", 0.36], ["total_event_registrations_goal", "total_maintainer_records", 0.4444444444444444], ["total_event_registrations_goal", "total_maintainers", 0.425531914893617], ["total_event_registrations_goal", "total_next_membership_revenue", 0.3728813559322034], ["total_event_registrations_goal", "total_registration_net_revenue", 0.6333333333333333], ["total_event_registrations_goal", "total_registration_tax", 0.7692307692307693], ["total_event_registrations_goal", "total_registrations", 0.7755102040816326], ["total_event_registrations_goal", "total_software_value", 0.48], ["total_event_registrations_goal", "total_speakers", 0.45454545454545453], ["total_event_registrations_goal", "total_speaking_engagements", 0.39285714285714285], ["total_event_registrations_goal", "total_sponsorship_count", 0.4528301886792453], ["total_event_registrations_goal", "total_sponsorship_revenue", 0.4], ["total_event_registrations_goal", "training_enrollments", 0.32], ["total_event_registrations_goal", "upcoming_events_count", 0.39215686274509803], ["total_events", "active_maintainer_records", 0.32432432432432434], ["total_events", "active_maintainers", 0.3333333333333333], ["total_events", "approved_pull_requests", 0.4117647058823529], ["total_events", "avg_project_health_score", 0.2777777777777778], ["total_events", "bot_activities", 0.38461538461538464], ["total_events", "certification_enrollments", 0.43243243243243246], ["total_events", "churned_membership_count", 0.16666666666666666], ["total_events", "churned_membership_discount_amount", 0.13043478260869565], ["total_events", "churned_membership_invoice_amount", 0.17777777777777778], ["total_events", "code_contribution_activities", 0.25], ["total_events", "current_membership_count", 0.2222222222222222], ["total_events", "current_membership_discount_amount", 0.17391304347826086], ["total_events", "current_membership_invoice_amount", 0.17777777777777778], ["total_events", "current_membership_revenue", 0.3157894736842105], ["total_events", "current_new_account_membership_count", 0.16666666666666666], ["total_events", "human_activities", 0.2857142857142857], ["total_events", "last_completed_year_active_discount_amount", 0.3333333333333333], ["total_events", "last_completed_year_active_invoice_amount", 0.33962264150943394], ["total_events", "last_completed_year_active_membership_count", 0.32727272727272727], ["total_events", "last_completed_year_active_membership_revenue", 0.3157894736842105], ["total_events", "lf_project_activities", 0.24242424242424243], ["total_events", "main_branch_commits", 0.1935483870967742], ["total_events", "membership_revenue", 0.3333333333333333], ["total_events", "past_event_speakers", 0.5161290322580645], ["total_events", "past_events_count", 0.5517241379310345], ["total_events", "project_count", 0.32], ["total_events", "project_health_count", 0.375], ["total_events", "renewal_price", 0.32], ["total_events", "sponsorship_quantity_total", 0.2631578947368421], ["total_events", "total_accepted_proposals", 0.5], ["total_events", "total_activities", 0.5714285714285714], ["total_events", "total_certifications", 0.5625], ["total_events", "total_code_deletions", 0.625], ["total_events", "total_code_insertions", 0.6060606060606061], ["total_events", "total_contributing_organizations", 0.4090909090909091], ["total_events", "total_contributors", 0.6], ["total_events", "total_discount_amount", 0.48484848484848486], ["total_events", "total_downgrade_churn_amount", 0.45], ["total_events", "total_enrolled_users", 0.5625], ["total_events", "total_enrollments", 0.7586206896551724], ["total_events", "total_estimated_cost", 0.5625], ["total_events", "total_event_registrations_goal", 0.5714285714285714], ["total_events", "total_events", 1.0], ["total_events", "total_first_time_contributors", 0.4878048780487805], ["total_events", "total_gross_revenue", 0.6451612903225806], ["total_events", "total_invoice_amount", 0.5625], ["total_events", "total_maintainer_records", 0.5], ["total_events", "total_maintainers", 0.6206896551724138], ["total_events", "total_next_membership_revenue", 0.4878048780487805], ["total_events", "total_registration_net_revenue", 0.47619047619047616], ["total_events", "total_registration_tax", 0.5294117647058824], ["total_events", "total_registrations", 0.5806451612903226], ["total_events", "total_software_value", 0.5625], ["total_events", "total_speakers", 0.6923076923076923], ["total_events", "total_speaking_engagements", 0.5789473684210527], ["total_events", "total_sponsorship_count", 0.45714285714285713], ["total_events", "total_sponsorship_revenue", 0.5405405405405406], ["total_events", "training_enrollments", 0.5], ["total_events", "upcoming_events_count", 0.48484848484848486], ["total_first_time_contributors", "active_maintainer_records", 0.37037037037037035], ["total_first_time_contributors", "active_maintainers", 0.2553191489361702], ["total_first_time_contributors", "approved_pull_requests", 0.27450980392156865], ["total_first_time_contributors", "avg_project_health_score", 0.3018867924528302], ["total_first_time_contributors", "bot_activities", 0.32558139534883723], ["total_first_time_contributors", "certification_enrollments", 0.37037037037037035], ["total_first_time_contributors", "churned_membership_count", 0.33962264150943394], ["total_first_time_contributors", "churned_membership_discount_amount", 0.2857142857142857], ["total_first_time_contributors", "churned_membership_invoice_amount", 0.3225806451612903], ["total_first_time_contributors", "code_contribution_activities", 0.49122807017543857], ["total_first_time_contributors", "current_membership_count", 0.37735849056603776], ["total_first_time_contributors", "current_membership_discount_amount", 0.31746031746031744], ["total_first_time_contributors", "current_membership_invoice_amount", 0.3548387096774194], ["total_first_time_contributors", "current_membership_revenue", 0.2909090909090909], ["total_first_time_contributors", "current_new_account_membership_count", 0.36923076923076925], ["total_first_time_contributors", "human_activities", 0.26666666666666666], ["total_first_time_contributors", "last_completed_year_active_discount_amount", 0.39436619718309857], ["total_first_time_contributors", "last_completed_year_active_invoice_amount", 0.34285714285714286], ["total_first_time_contributors", "last_completed_year_active_membership_count", 0.3611111111111111], ["total_first_time_contributors", "last_completed_year_active_membership_revenue", 0.2972972972972973], ["total_first_time_contributors", "lf_project_activities", 0.28], ["total_first_time_contributors", "main_branch_commits", 0.3333333333333333], ["total_first_time_contributors", "membership_revenue", 0.2553191489361702], ["total_first_time_contributors", "past_event_speakers", 0.375], ["total_first_time_contributors", "past_events_count", 0.43478260869565216], ["total_first_time_contributors", "project_count", 0.2857142857142857], ["total_first_time_contributors", "project_health_count", 0.3673469387755102], ["total_first_time_contributors", "renewal_price", 0.23809523809523808], ["total_first_time_contributors", "sponsorship_quantity_total", 0.18181818181818182], ["total_first_time_contributors", "total_accepted_proposals", 0.33962264150943394], ["total_first_time_contributors", "total_activities", 0.4444444444444444], ["total_first_time_contributors", "total_certifications", 0.5306122448979592], ["total_first_time_contributors", "total_code_deletions", 0.4897959183673469], ["total_first_time_contributors", "total_code_insertions", 0.52], ["total_first_time_contributors", "total_contributing_organizations", 0.5901639344262295], ["total_first_time_contributors", "total_contributors", 0.7659574468085106], ["total_first_time_contributors", "total_discount_amount", 0.56], ["total_first_time_contributors", "total_downgrade_churn_amount", 0.45614035087719296], ["total_first_time_contributors", "total_enrolled_users", 0.32653061224489793], ["total_first_time_contributors", "total_enrollments", 0.5217391304347826], ["total_first_time_contributors", "total_estimated_cost", 0.6122448979591837], ["total_first_time_contributors", "total_event_registrations_goal", 0.4745762711864407], ["total_first_time_contributors", "total_events", 0.4878048780487805], ["total_first_time_contributors", "total_first_time_contributors", 1.0], ["total_first_time_contributors", "total_gross_revenue", 0.5], ["total_first_time_contributors", "total_invoice_amount", 0.5306122448979592], ["total_first_time_contributors", "total_maintainer_records", 0.5283018867924528], ["total_first_time_contributors", "total_maintainers", 0.391304347826087], ["total_first_time_contributors", "total_next_membership_revenue", 0.41379310344827586], ["total_first_time_contributors", "total_registration_net_revenue", 0.5423728813559322], ["total_first_time_contributors", "total_registration_tax", 0.43137254901960786], ["total_first_time_contributors", "total_registrations", 0.5833333333333334], ["total_first_time_contributors", "total_software_value", 0.4489795918367347], ["total_first_time_contributors", "total_speakers", 0.37209302325581395], ["total_first_time_contributors", "total_speaking_engagements", 0.4727272727272727], ["total_first_time_contributors", "total_sponsorship_count", 0.5384615384615384], ["total_first_time_contributors", "total_sponsorship_revenue", 0.4444444444444444], ["total_first_time_contributors", "training_enrollments", 0.32653061224489793], ["total_first_time_contributors", "upcoming_events_count", 0.28], ["total_gross_revenue", "active_maintainer_records", 0.3181818181818182], ["total_gross_revenue", "active_maintainers", 0.2702702702702703], ["total_gross_revenue", "approved_pull_requests", 0.3902439024390244], ["total_gross_revenue", "avg_project_health_score", 0.32558139534883723], ["total_gross_revenue", "bot_activities", 0.24242424242424243], ["total_gross_revenue", "certification_enrollments", 0.3181818181818182], ["total_gross_revenue", "churned_membership_count", 0.046511627906976744], ["total_gross_revenue", "churned_membership_discount_amount", 0.11320754716981132], ["total_gross_revenue", "churned_membership_invoice_amount", 0.038461538461538464], ["total_gross_revenue", "code_contribution_activities", 0.1702127659574468], ["total_gross_revenue", "current_membership_count", 0.23255813953488372], ["total_gross_revenue", "current_membership_discount_amount", 0.22641509433962265], ["total_gross_revenue", "current_membership_invoice_amount", 0.23076923076923078], ["total_gross_revenue", "current_membership_revenue", 0.5333333333333333], ["total_gross_revenue", "current_new_account_membership_count", 0.21818181818181817], ["total_gross_revenue", "human_activities", 0.17142857142857143], ["total_gross_revenue", "last_completed_year_active_discount_amount", 0.29508196721311475], ["total_gross_revenue", "last_completed_year_active_invoice_amount", 0.3], ["total_gross_revenue", "last_completed_year_active_membership_count", 0.25806451612903225], ["total_gross_revenue", "last_completed_year_active_membership_revenue", 0.46875], ["total_gross_revenue", "lf_project_activities", 0.25], ["total_gross_revenue", "main_branch_commits", 0.10526315789473684], ["total_gross_revenue", "membership_revenue", 0.5405405405405406], ["total_gross_revenue", "past_event_speakers", 0.3684210526315789], ["total_gross_revenue", "past_events_count", 0.3888888888888889], ["total_gross_revenue", "project_count", 0.25], ["total_gross_revenue", "project_health_count", 0.3076923076923077], ["total_gross_revenue", "renewal_price", 0.3125], ["total_gross_revenue", "sponsorship_quantity_total", 0.2222222222222222], ["total_gross_revenue", "total_accepted_proposals", 0.46511627906976744], ["total_gross_revenue", "total_activities", 0.4], ["total_gross_revenue", "total_certifications", 0.46153846153846156], ["total_gross_revenue", "total_code_deletions", 0.41025641025641024], ["total_gross_revenue", "total_code_insertions", 0.45], ["total_gross_revenue", "total_contributing_organizations", 0.39215686274509803], ["total_gross_revenue", "total_contributors", 0.4864864864864865], ["total_gross_revenue", "total_discount_amount", 0.45], ["total_gross_revenue", "total_downgrade_churn_amount", 0.425531914893617], ["total_gross_revenue", "total_enrolled_users", 0.5128205128205128], ["total_gross_revenue", "total_enrollments", 0.5555555555555556], ["total_gross_revenue", "total_estimated_cost", 0.41025641025641024], ["total_gross_revenue", "total_event_registrations_goal", 0.4489795918367347], ["total_gross_revenue", "total_events", 0.6451612903225806], ["total_gross_revenue", "total_first_time_contributors", 0.375], ["total_gross_revenue", "total_gross_revenue", 1.0], ["total_gross_revenue", "total_invoice_amount", 0.46153846153846156], ["total_gross_revenue", "total_maintainer_records", 0.46511627906976744], ["total_gross_revenue", "total_maintainers", 0.4444444444444444], ["total_gross_revenue", "total_next_membership_revenue", 0.6666666666666666], ["total_gross_revenue", "total_registration_net_revenue", 0.6938775510204082], ["total_gross_revenue", "total_registration_tax", 0.43902439024390244], ["total_gross_revenue", "total_registrations", 0.47368421052631576], ["total_gross_revenue", "total_software_value", 0.6153846153846154], ["total_gross_revenue", "total_speakers", 0.48484848484848486], ["total_gross_revenue", "total_speaking_engagements", 0.4888888888888889], ["total_gross_revenue", "total_sponsorship_count", 0.42857142857142855], ["total_gross_revenue", "total_sponsorship_revenue", 0.7272727272727273], ["total_gross_revenue", "training_enrollments", 0.358974358974359], ["total_gross_revenue", "upcoming_events_count", 0.35], ["total_invoice_amount", "active_maintainer_records", 0.26666666666666666], ["total_invoice_amount", "active_maintainers", 0.3157894736842105], ["total_invoice_amount", "approved_pull_requests", 0.2857142857142857], ["total_invoice_amount", "avg_project_health_score", 0.2727272727272727], ["total_invoice_amount", "bot_activities", 0.29411764705882354], ["total_invoice_amount", "certification_enrollments", 0.35555555555555557], ["total_invoice_amount", "churned_membership_count", 0.3181818181818182], ["total_invoice_amount", "churned_membership_discount_amount", 0.2962962962962963], ["total_invoice_amount", "churned_membership_invoice_amount", 0.5660377358490566], ["total_invoice_amount", "code_contribution_activities", 0.25], ["total_invoice_amount", "current_membership_count", 0.36363636363636365], ["total_invoice_amount", "current_membership_discount_amount", 0.37037037037037035], ["total_invoice_amount", "current_membership_invoice_amount", 0.6037735849056604], ["total_invoice_amount", "current_membership_revenue", 0.17391304347826086], ["total_invoice_amount", "current_new_account_membership_count", 0.35714285714285715], ["total_invoice_amount", "human_activities", 0.2777777777777778], ["total_invoice_amount", "last_completed_year_active_discount_amount", 0.45161290322580644], ["total_invoice_amount", "last_completed_year_active_invoice_amount", 0.6229508196721312], ["total_invoice_amount", "last_completed_year_active_membership_count", 0.4444444444444444], ["total_invoice_amount", "last_completed_year_active_membership_revenue", 0.3384615384615385], ["total_invoice_amount", "lf_project_activities", 0.1951219512195122], ["total_invoice_amount", "main_branch_commits", 0.3076923076923077], ["total_invoice_amount", "membership_revenue", 0.15789473684210525], ["total_invoice_amount", "past_event_speakers", 0.3076923076923077], ["total_invoice_amount", "past_events_count", 0.43243243243243246], ["total_invoice_amount", "project_count", 0.42424242424242425], ["total_invoice_amount", "project_health_count", 0.45], ["total_invoice_amount", "renewal_price", 0.36363636363636365], ["total_invoice_amount", "sponsorship_quantity_total", 0.21739130434782608], ["total_invoice_amount", "total_accepted_proposals", 0.45454545454545453], ["total_invoice_amount", "total_activities", 0.5555555555555556], ["total_invoice_amount", "total_certifications", 0.6], ["total_invoice_amount", "total_code_deletions", 0.55], ["total_invoice_amount", "total_code_insertions", 0.4878048780487805], ["total_invoice_amount", "total_contributing_organizations", 0.5], ["total_invoice_amount", "total_contributors", 0.47368421052631576], ["total_invoice_amount", "total_discount_amount", 0.7317073170731707], ["total_invoice_amount", "total_downgrade_churn_amount", 0.625], ["total_invoice_amount", "total_enrolled_users", 0.55], ["total_invoice_amount", "total_enrollments", 0.5945945945945946], ["total_invoice_amount", "total_estimated_cost", 0.45], ["total_invoice_amount", "total_event_registrations_goal", 0.4], ["total_invoice_amount", "total_events", 0.625], ["total_invoice_amount", "total_first_time_contributors", 0.5306122448979592], ["total_invoice_amount", "total_gross_revenue", 0.41025641025641024], ["total_invoice_amount", "total_invoice_amount", 1.0], ["total_invoice_amount", "total_maintainer_records", 0.4090909090909091], ["total_invoice_amount", "total_maintainers", 0.5405405405405406], ["total_invoice_amount", "total_next_membership_revenue", 0.3673469387755102], ["total_invoice_amount", "total_registration_net_revenue", 0.44], ["total_invoice_amount", "total_registration_tax", 0.47619047619047616], ["total_invoice_amount", "total_registrations", 0.41025641025641024], ["total_invoice_amount", "total_software_value", 0.55], ["total_invoice_amount", "total_speakers", 0.47058823529411764], ["total_invoice_amount", "total_speaking_engagements", 0.5652173913043478], ["total_invoice_amount", "total_sponsorship_count", 0.5581395348837209], ["total_invoice_amount", "total_sponsorship_revenue", 0.4], ["total_invoice_amount", "training_enrollments", 0.4], ["total_invoice_amount", "upcoming_events_count", 0.43902439024390244], ["total_maintainer_records", "active_maintainer_records", 0.8163265306122449], ["total_maintainer_records", "active_maintainers", 0.6190476190476191], ["total_maintainer_records", "approved_pull_requests", 0.2608695652173913], ["total_maintainer_records", "avg_project_health_score", 0.2916666666666667], ["total_maintainer_records", "bot_activities", 0.42105263157894735], ["total_maintainer_records", "certification_enrollments", 0.2857142857142857], ["total_maintainer_records", "churned_membership_count", 0.20833333333333334], ["total_maintainer_records", "churned_membership_discount_amount", 0.2413793103448276], ["total_maintainer_records", "churned_membership_invoice_amount", 0.17543859649122806], ["total_maintainer_records", "code_contribution_activities", 0.3076923076923077], ["total_maintainer_records", "current_membership_count", 0.25], ["total_maintainer_records", "current_membership_discount_amount", 0.27586206896551724], ["total_maintainer_records", "current_membership_invoice_amount", 0.21052631578947367], ["total_maintainer_records", "current_membership_revenue", 0.32], ["total_maintainer_records", "current_new_account_membership_count", 0.26666666666666666], ["total_maintainer_records", "human_activities", 0.35], ["total_maintainer_records", "last_completed_year_active_discount_amount", 0.3333333333333333], ["total_maintainer_records", "last_completed_year_active_invoice_amount", 0.27692307692307694], ["total_maintainer_records", "last_completed_year_active_membership_count", 0.26865671641791045], ["total_maintainer_records", "last_completed_year_active_membership_revenue", 0.3188405797101449], ["total_maintainer_records", "lf_project_activities", 0.17777777777777778], ["total_maintainer_records", "main_branch_commits", 0.46511627906976744], ["total_maintainer_records", "membership_revenue", 0.2857142857142857], ["total_maintainer_records", "past_event_speakers", 0.37209302325581395], ["total_maintainer_records", "past_events_count", 0.34146341463414637], ["total_maintainer_records", "project_count", 0.21621621621621623], ["total_maintainer_records", "project_health_count", 0.2727272727272727], ["total_maintainer_records", "renewal_price", 0.2702702702702703], ["total_maintainer_records", "sponsorship_quantity_total", 0.2], ["total_maintainer_records", "total_accepted_proposals", 0.4166666666666667], ["total_maintainer_records", "total_activities", 0.6], ["total_maintainer_records", "total_certifications", 0.5], ["total_maintainer_records", "total_code_deletions", 0.45454545454545453], ["total_maintainer_records", "total_code_insertions", 0.5333333333333333], ["total_maintainer_records", "total_contributing_organizations", 0.42857142857142855], ["total_maintainer_records", "total_contributors", 0.5714285714285714], ["total_maintainer_records", "total_discount_amount", 0.4888888888888889], ["total_maintainer_records", "total_downgrade_churn_amount", 0.34615384615384615], ["total_maintainer_records", "total_enrolled_users", 0.45454545454545453], ["total_maintainer_records", "total_enrollments", 0.4878048780487805], ["total_maintainer_records", "total_estimated_cost", 0.6363636363636364], ["total_maintainer_records", "total_event_registrations_goal", 0.48148148148148145], ["total_maintainer_records", "total_events", 0.5], ["total_maintainer_records", "total_first_time_contributors", 0.49056603773584906], ["total_maintainer_records", "total_gross_revenue", 0.46511627906976744], ["total_maintainer_records", "total_invoice_amount", 0.4090909090909091], ["total_maintainer_records", "total_maintainer_records", 1.0], ["total_maintainer_records", "total_maintainers", 0.8292682926829268], ["total_maintainer_records", "total_next_membership_revenue", 0.4528301886792453], ["total_maintainer_records", "total_registration_net_revenue", 0.5185185185185185], ["total_maintainer_records", "total_registration_tax", 0.4782608695652174], ["total_maintainer_records", "total_registrations", 0.46511627906976744], ["total_maintainer_records", "total_software_value", 0.4090909090909091], ["total_maintainer_records", "total_speakers", 0.5263157894736842], ["total_maintainer_records", "total_speaking_engagements", 0.44], ["total_maintainer_records", "total_sponsorship_count", 0.3829787234042553], ["total_maintainer_records", "total_sponsorship_revenue", 0.40816326530612246], ["total_maintainer_records", "training_enrollments", 0.45454545454545453], ["total_maintainer_records", "upcoming_events_count", 0.35555555555555557], ["total_maintainers", "active_maintainer_records", 0.6190476190476191], ["total_maintainers", "active_maintainers", 0.7428571428571429], ["total_maintainers", "approved_pull_requests", 0.2564102564102564], ["total_maintainers", "avg_project_health_score", 0.24390243902439024], ["total_maintainers", "bot_activities", 0.5161290322580645], ["total_maintainers", "certification_enrollments", 0.3333333333333333], ["total_maintainers", "churned_membership_count", 0.24390243902439024], ["total_maintainers", "churned_membership_discount_amount", 0.19607843137254902], ["total_maintainers", "churned_membership_invoice_amount", 0.2], ["total_maintainers", "code_contribution_activities", 0.35555555555555557], ["total_maintainers", "current_membership_count", 0.2926829268292683], ["total_maintainers", "current_membership_discount_amount", 0.23529411764705882], ["total_maintainers", "current_membership_invoice_amount", 0.24], ["total_maintainers", "current_membership_revenue", 0.27906976744186046], ["total_maintainers", "current_new_account_membership_count", 0.3018867924528302], ["total_maintainers", "human_activities", 0.42424242424242425], ["total_maintainers", "last_completed_year_active_discount_amount", 0.3728813559322034], ["total_maintainers", "last_completed_year_active_invoice_amount", 0.3103448275862069], ["total_maintainers", "last_completed_year_active_membership_count", 0.3], ["total_maintainers", "last_completed_year_active_membership_revenue", 0.2903225806451613], ["total_maintainers", "lf_project_activities", 0.3684210526315789], ["total_maintainers", "main_branch_commits", 0.3333333333333333], ["total_maintainers", "membership_revenue", 0.22857142857142856], ["total_maintainers", "past_event_speakers", 0.4444444444444444], ["total_maintainers", "past_events_count", 0.29411764705882354], ["total_maintainers", "project_count", 0.26666666666666666], ["total_maintainers", "project_health_count", 0.32432432432432434], ["total_maintainers", "renewal_price", 0.3333333333333333], ["total_maintainers", "sponsorship_quantity_total", 0.23255813953488372], ["total_maintainers", "total_accepted_proposals", 0.4878048780487805], ["total_maintainers", "total_activities", 0.7272727272727273], ["total_maintainers", "total_certifications", 0.4864864864864865], ["total_maintainers", "total_code_deletions", 0.4864864864864865], ["total_maintainers", "total_code_insertions", 0.5789473684210527], ["total_maintainers", "total_contributing_organizations", 0.4897959183673469], ["total_maintainers", "total_contributors", 0.6285714285714286], ["total_maintainers", "total_discount_amount", 0.5789473684210527], ["total_maintainers", "total_downgrade_churn_amount", 0.4], ["total_maintainers", "total_enrolled_users", 0.5405405405405406], ["total_maintainers", "total_enrollments", 0.5882352941176471], ["total_maintainers", "total_estimated_cost", 0.5945945945945946], ["total_maintainers", "total_event_registrations_goal", 0.5106382978723404], ["total_maintainers", "total_events", 0.6206896551724138], ["total_maintainers", "total_first_time_contributors", 0.5217391304347826], ["total_maintainers", "total_gross_revenue", 0.4444444444444444], ["total_maintainers", "total_invoice_amount", 0.4864864864864865], ["total_maintainers", "total_maintainer_records", 0.8292682926829268], ["total_maintainers", "total_maintainers", 1.0], ["total_maintainers", "total_next_membership_revenue", 0.43478260869565216], ["total_maintainers", "total_registration_net_revenue", 0.5106382978723404], ["total_maintainers", "total_registration_tax", 0.5641025641025641], ["total_maintainers", "total_registrations", 0.5555555555555556], ["total_maintainers", "total_software_value", 0.4864864864864865], ["total_maintainers", "total_speakers", 0.6451612903225806], ["total_maintainers", "total_speaking_engagements", 0.5116279069767442], ["total_maintainers", "total_sponsorship_count", 0.45], ["total_maintainers", "total_sponsorship_revenue", 0.42857142857142855], ["total_maintainers", "training_enrollments", 0.4864864864864865], ["total_maintainers", "upcoming_events_count", 0.3157894736842105], ["total_next_membership_revenue", "active_maintainer_records", 0.3333333333333333], ["total_next_membership_revenue", "active_maintainers", 0.2978723404255319], ["total_next_membership_revenue", "approved_pull_requests", 0.27450980392156865], ["total_next_membership_revenue", "avg_project_health_score", 0.2641509433962264], ["total_next_membership_revenue", "bot_activities", 0.23255813953488372], ["total_next_membership_revenue", "certification_enrollments", 0.2222222222222222], ["total_next_membership_revenue", "churned_membership_count", 0.5660377358490566], ["total_next_membership_revenue", "churned_membership_discount_amount", 0.5079365079365079], ["total_next_membership_revenue", "churned_membership_invoice_amount", 0.5161290322580645], ["total_next_membership_revenue", "code_contribution_activities", 0.17543859649122806], ["total_next_membership_revenue", "current_membership_count", 0.5660377358490566], ["total_next_membership_revenue", "current_membership_discount_amount", 0.5079365079365079], ["total_next_membership_revenue", "current_membership_invoice_amount", 0.5161290322580645], ["total_next_membership_revenue", "current_membership_revenue", 0.7636363636363637], ["total_next_membership_revenue", "current_new_account_membership_count", 0.5538461538461539], ["total_next_membership_revenue", "human_activities", 0.17777777777777778], ["total_next_membership_revenue", "last_completed_year_active_discount_amount", 0.3380281690140845], ["total_next_membership_revenue", "last_completed_year_active_invoice_amount", 0.34285714285714286], ["total_next_membership_revenue", "last_completed_year_active_membership_count", 0.5277777777777778], ["total_next_membership_revenue", "last_completed_year_active_membership_revenue", 0.6756756756756757], ["total_next_membership_revenue", "lf_project_activities", 0.24], ["total_next_membership_revenue", "main_branch_commits", 0.08333333333333333], ["total_next_membership_revenue", "membership_revenue", 0.7659574468085106], ["total_next_membership_revenue", "past_event_speakers", 0.3333333333333333], ["total_next_membership_revenue", "past_events_count", 0.34782608695652173], ["total_next_membership_revenue", "project_count", 0.23809523809523808], ["total_next_membership_revenue", "project_health_count", 0.24489795918367346], ["total_next_membership_revenue", "renewal_price", 0.19047619047619047], ["total_next_membership_revenue", "sponsorship_quantity_total", 0.32727272727272727], ["total_next_membership_revenue", "total_accepted_proposals", 0.41509433962264153], ["total_next_membership_revenue", "total_activities", 0.35555555555555557], ["total_next_membership_revenue", "total_certifications", 0.3673469387755102], ["total_next_membership_revenue", "total_code_deletions", 0.32653061224489793], ["total_next_membership_revenue", "total_code_insertions", 0.4], ["total_next_membership_revenue", "total_contributing_organizations", 0.36065573770491804], ["total_next_membership_revenue", "total_contributors", 0.46808510638297873], ["total_next_membership_revenue", "total_discount_amount", 0.44], ["total_next_membership_revenue", "total_downgrade_churn_amount", 0.3157894736842105], ["total_next_membership_revenue", "total_enrolled_users", 0.4897959183673469], ["total_next_membership_revenue", "total_enrollments", 0.43478260869565216], ["total_next_membership_revenue", "total_estimated_cost", 0.40816326530612246], ["total_next_membership_revenue", "total_event_registrations_goal", 0.3728813559322034], ["total_next_membership_revenue", "total_events", 0.4878048780487805], ["total_next_membership_revenue", "total_first_time_contributors", 0.4482758620689655], ["total_next_membership_revenue", "total_gross_revenue", 0.6666666666666666], ["total_next_membership_revenue", "total_invoice_amount", 0.3673469387755102], ["total_next_membership_revenue", "total_maintainer_records", 0.4528301886792453], ["total_next_membership_revenue", "total_maintainers", 0.4782608695652174], ["total_next_membership_revenue", "total_next_membership_revenue", 1.0], ["total_next_membership_revenue", "total_registration_net_revenue", 0.576271186440678], ["total_next_membership_revenue", "total_registration_tax", 0.35294117647058826], ["total_next_membership_revenue", "total_registrations", 0.375], ["total_next_membership_revenue", "total_software_value", 0.4897959183673469], ["total_next_membership_revenue", "total_speakers", 0.46511627906976744], ["total_next_membership_revenue", "total_speaking_engagements", 0.4], ["total_next_membership_revenue", "total_sponsorship_count", 0.5384615384615384], ["total_next_membership_revenue", "total_sponsorship_revenue", 0.7407407407407407], ["total_next_membership_revenue", "training_enrollments", 0.24489795918367346], ["total_next_membership_revenue", "upcoming_events_count", 0.28], ["total_registration_net_revenue", "active_maintainer_records", 0.2545454545454545], ["total_registration_net_revenue", "active_maintainers", 0.2916666666666667], ["total_registration_net_revenue", "approved_pull_requests", 0.2692307692307692], ["total_registration_net_revenue", "avg_project_health_score", 0.2222222222222222], ["total_registration_net_revenue", "bot_activities", 0.2727272727272727], ["total_registration_net_revenue", "certification_enrollments", 0.4], ["total_registration_net_revenue", "churned_membership_count", 0.14814814814814814], ["total_registration_net_revenue", "churned_membership_discount_amount", 0.28125], ["total_registration_net_revenue", "churned_membership_invoice_amount", 0.12698412698412698], ["total_registration_net_revenue", "code_contribution_activities", 0.3448275862068966], ["total_registration_net_revenue", "current_membership_count", 0.25925925925925924], ["total_registration_net_revenue", "current_membership_discount_amount", 0.28125], ["total_registration_net_revenue", "current_membership_invoice_amount", 0.25396825396825395], ["total_registration_net_revenue", "current_membership_revenue", 0.39285714285714285], ["total_registration_net_revenue", "current_new_account_membership_count", 0.30303030303030304], ["total_registration_net_revenue", "human_activities", 0.2608695652173913], ["total_registration_net_revenue", "last_completed_year_active_discount_amount", 0.3611111111111111], ["total_registration_net_revenue", "last_completed_year_active_invoice_amount", 0.30985915492957744], ["total_registration_net_revenue", "last_completed_year_active_membership_count", 0.273972602739726], ["total_registration_net_revenue", "last_completed_year_active_membership_revenue", 0.4533333333333333], ["total_registration_net_revenue", "lf_project_activities", 0.19607843137254902], ["total_registration_net_revenue", "main_branch_commits", 0.20408163265306123], ["total_registration_net_revenue", "membership_revenue", 0.4166666666666667], ["total_registration_net_revenue", "past_event_speakers", 0.3673469387755102], ["total_registration_net_revenue", "past_events_count", 0.3829787234042553], ["total_registration_net_revenue", "project_count", 0.23255813953488372], ["total_registration_net_revenue", "project_health_count", 0.2], ["total_registration_net_revenue", "renewal_price", 0.23255813953488372], ["total_registration_net_revenue", "sponsorship_quantity_total", 0.17857142857142858], ["total_registration_net_revenue", "total_accepted_proposals", 0.3333333333333333], ["total_registration_net_revenue", "total_activities", 0.43478260869565216], ["total_registration_net_revenue", "total_certifications", 0.52], ["total_registration_net_revenue", "total_code_deletions", 0.44], ["total_registration_net_revenue", "total_code_insertions", 0.43137254901960786], ["total_registration_net_revenue", "total_contributing_organizations", 0.41935483870967744], ["total_registration_net_revenue", "total_contributors", 0.4583333333333333], ["total_registration_net_revenue", "total_discount_amount", 0.5098039215686274], ["total_registration_net_revenue", "total_downgrade_churn_amount", 0.4482758620689655], ["total_registration_net_revenue", "total_enrolled_users", 0.4], ["total_registration_net_revenue", "total_enrollments", 0.3829787234042553], ["total_registration_net_revenue", "total_estimated_cost", 0.52], ["total_registration_net_revenue", "total_event_registrations_goal", 0.6333333333333333], ["total_registration_net_revenue", "total_events", 0.47619047619047616], ["total_registration_net_revenue", "total_first_time_contributors", 0.5423728813559322], ["total_registration_net_revenue", "total_gross_revenue", 0.6530612244897959], ["total_registration_net_revenue", "total_invoice_amount", 0.32], ["total_registration_net_revenue", "total_maintainer_records", 0.5555555555555556], ["total_registration_net_revenue", "total_maintainers", 0.5531914893617021], ["total_registration_net_revenue", "total_next_membership_revenue", 0.576271186440678], ["total_registration_net_revenue", "total_registration_net_revenue", 1.0], ["total_registration_net_revenue", "total_registration_tax", 0.7692307692307693], ["total_registration_net_revenue", "total_registrations", 0.7346938775510204], ["total_registration_net_revenue", "total_software_value", 0.44], ["total_registration_net_revenue", "total_speakers", 0.36363636363636365], ["total_registration_net_revenue", "total_speaking_engagements", 0.42857142857142855], ["total_registration_net_revenue", "total_sponsorship_count", 0.4528301886792453], ["total_registration_net_revenue", "total_sponsorship_revenue", 0.6181818181818182], ["total_registration_net_revenue", "training_enrollments", 0.36], ["total_registration_net_revenue", "upcoming_events_count", 0.27450980392156865], ["total_registration_tax", "active_maintainer_records", 0.2978723404255319], ["total_registration_tax", "active_maintainers", 0.25], ["total_registration_tax", "approved_pull_requests", 0.3181818181818182], ["total_registration_tax", "avg_project_health_score", 0.2608695652173913], ["total_registration_tax", "bot_activities", 0.3333333333333333], ["total_registration_tax", "certification_enrollments", 0.3829787234042553], ["total_registration_tax", "churned_membership_count", 0.043478260869565216], ["total_registration_tax", "churned_membership_discount_amount", 0.25], ["total_registration_tax", "churned_membership_invoice_amount", 0.03636363636363636], ["total_registration_tax", "code_contribution_activities", 0.4], ["total_registration_tax", "current_membership_count", 0.17391304347826086], ["total_registration_tax", "current_membership_discount_amount", 0.25], ["total_registration_tax", "current_membership_invoice_amount", 0.14545454545454545], ["total_registration_tax", "current_membership_revenue", 0.20833333333333334], ["total_registration_tax", "current_new_account_membership_count", 0.13793103448275862], ["total_registration_tax", "human_activities", 0.3157894736842105], ["total_registration_tax", "last_completed_year_active_discount_amount", 0.34375], ["total_registration_tax", "last_completed_year_active_invoice_amount", 0.31746031746031744], ["total_registration_tax", "last_completed_year_active_membership_count", 0.3076923076923077], ["total_registration_tax", "last_completed_year_active_membership_revenue", 0.23880597014925373], ["total_registration_tax", "lf_project_activities", 0.23255813953488372], ["total_registration_tax", "main_branch_commits", 0.24390243902439024], ["total_registration_tax", "membership_revenue", 0.2], ["total_registration_tax", "past_event_speakers", 0.1951219512195122], ["total_registration_tax", "past_events_count", 0.358974358974359], ["total_registration_tax", "project_count", 0.17142857142857143], ["total_registration_tax", "project_health_count", 0.23809523809523808], ["total_registration_tax", "renewal_price", 0.2857142857142857], ["total_registration_tax", "sponsorship_quantity_total", 0.20833333333333334], ["total_registration_tax", "total_accepted_proposals", 0.391304347826087], ["total_registration_tax", "total_activities", 0.5263157894736842], ["total_registration_tax", "total_certifications", 0.6190476190476191], ["total_registration_tax", "total_code_deletions", 0.5238095238095238], ["total_registration_tax", "total_code_insertions", 0.5116279069767442], ["total_registration_tax", "total_contributing_organizations", 0.48148148148148145], ["total_registration_tax", "total_contributors", 0.5], ["total_registration_tax", "total_discount_amount", 0.5116279069767442], ["total_registration_tax", "total_downgrade_churn_amount", 0.48], ["total_registration_tax", "total_enrolled_users", 0.47619047619047616], ["total_registration_tax", "total_enrollments", 0.46153846153846156], ["total_registration_tax", "total_estimated_cost", 0.6190476190476191], ["total_registration_tax", "total_event_registrations_goal", 0.7692307692307693], ["total_registration_tax", "total_events", 0.47058823529411764], ["total_registration_tax", "total_first_time_contributors", 0.5490196078431373], ["total_registration_tax", "total_gross_revenue", 0.43902439024390244], ["total_registration_tax", "total_invoice_amount", 0.38095238095238093], ["total_registration_tax", "total_maintainer_records", 0.391304347826087], ["total_registration_tax", "total_maintainers", 0.5128205128205128], ["total_registration_tax", "total_next_membership_revenue", 0.35294117647058826], ["total_registration_tax", "total_registration_net_revenue", 0.7692307692307693], ["total_registration_tax", "total_registration_tax", 1.0], ["total_registration_tax", "total_registrations", 0.8780487804878049], ["total_registration_tax", "total_software_value", 0.42857142857142855], ["total_registration_tax", "total_speakers", 0.4444444444444444], ["total_registration_tax", "total_speaking_engagements", 0.375], ["total_registration_tax", "total_sponsorship_count", 0.4888888888888889], ["total_registration_tax", "total_sponsorship_revenue", 0.3829787234042553], ["total_registration_tax", "training_enrollments", 0.19047619047619047], ["total_registration_tax", "upcoming_events_count", 0.13953488372093023], ["total_registrations", "active_maintainer_records", 0.3181818181818182], ["total_registrations", "active_maintainers", 0.2702702702702703], ["total_registrations", "approved_pull_requests", 0.3902439024390244], ["total_registrations", "avg_project_health_score", 0.27906976744186046], ["total_registrations", "bot_activities", 0.36363636363636365], ["total_registrations", "certification_enrollments", 0.36363636363636365], ["total_registrations", "churned_membership_count", 0.046511627906976744], ["total_registrations", "churned_membership_discount_amount", 0.2641509433962264], ["total_registrations", "churned_membership_invoice_amount", 0.038461538461538464], ["total_registrations", "code_contribution_activities", 0.3829787234042553], ["total_registrations", "current_membership_count", 0.18604651162790697], ["total_registrations", "current_membership_discount_amount", 0.2641509433962264], ["total_registrations", "current_membership_invoice_amount", 0.15384615384615385], ["total_registrations", "current_membership_revenue", 0.2222222222222222], ["total_registrations", "current_new_account_membership_count", 0.14545454545454545], ["total_registrations", "human_activities", 0.34285714285714286], ["total_registrations", "last_completed_year_active_discount_amount", 0.36065573770491804], ["total_registrations", "last_completed_year_active_invoice_amount", 0.3], ["total_registrations", "last_completed_year_active_membership_count", 0.2903225806451613], ["total_registrations", "last_completed_year_active_membership_revenue", 0.25], ["total_registrations", "lf_project_activities", 0.25], ["total_registrations", "main_branch_commits", 0.3157894736842105], ["total_registrations", "membership_revenue", 0.21621621621621623], ["total_registrations", "past_event_speakers", 0.2631578947368421], ["total_registrations", "past_events_count", 0.3333333333333333], ["total_registrations", "project_count", 0.1875], ["total_registrations", "project_health_count", 0.2564102564102564], ["total_registrations", "renewal_price", 0.3125], ["total_registrations", "sponsorship_quantity_total", 0.2222222222222222], ["total_registrations", "total_accepted_proposals", 0.46511627906976744], ["total_registrations", "total_activities", 0.5714285714285714], ["total_registrations", "total_certifications", 0.717948717948718], ["total_registrations", "total_code_deletions", 0.6153846153846154], ["total_registrations", "total_code_insertions", 0.6], ["total_registrations", "total_contributing_organizations", 0.5490196078431373], ["total_registrations", "total_contributors", 0.5945945945945946], ["total_registrations", "total_discount_amount", 0.55], ["total_registrations", "total_downgrade_churn_amount", 0.425531914893617], ["total_registrations", "total_enrolled_users", 0.5641025641025641], ["total_registrations", "total_enrollments", 0.5], ["total_registrations", "total_estimated_cost", 0.6666666666666666], ["total_registrations", "total_event_registrations_goal", 0.7755102040816326], ["total_registrations", "total_events", 0.5161290322580645], ["total_registrations", "total_first_time_contributors", 0.5833333333333334], ["total_registrations", "total_gross_revenue", 0.47368421052631576], ["total_registrations", "total_invoice_amount", 0.41025641025641024], ["total_registrations", "total_maintainer_records", 0.4186046511627907], ["total_registrations", "total_maintainers", 0.4444444444444444], ["total_registrations", "total_next_membership_revenue", 0.375], ["total_registrations", "total_registration_net_revenue", 0.7346938775510204], ["total_registrations", "total_registration_tax", 0.8780487804878049], ["total_registrations", "total_registrations", 1.0], ["total_registrations", "total_software_value", 0.46153846153846156], ["total_registrations", "total_speakers", 0.48484848484848486], ["total_registrations", "total_speaking_engagements", 0.4], ["total_registrations", "total_sponsorship_count", 0.47619047619047616], ["total_registrations", "total_sponsorship_revenue", 0.45454545454545453], ["total_registrations", "training_enrollments", 0.2564102564102564], ["total_registrations", "upcoming_events_count", 0.15], ["total_software_value", "active_maintainer_records", 0.26666666666666666], ["total_software_value", "active_maintainers", 0.21052631578947367], ["total_software_value", "approved_pull_requests", 0.3333333333333333], ["total_software_value", "avg_project_health_score", 0.36363636363636365], ["total_software_value", "bot_activities", 0.23529411764705882], ["total_software_value", "certification_enrollments", 0.17777777777777778], ["total_software_value", "churned_membership_count", 0.045454545454545456], ["total_software_value", "churned_membership_discount_amount", 0.1111111111111111], ["total_software_value", "churned_membership_invoice_amount", 0.18867924528301888], ["total_software_value", "code_contribution_activities", 0.20833333333333334], ["total_software_value", "current_membership_count", 0.18181818181818182], ["total_software_value", "current_membership_discount_amount", 0.18518518518518517], ["total_software_value", "current_membership_invoice_amount", 0.22641509433962265], ["total_software_value", "current_membership_revenue", 0.2608695652173913], ["total_software_value", "current_new_account_membership_count", 0.21428571428571427], ["total_software_value", "human_activities", 0.16666666666666666], ["total_software_value", "last_completed_year_active_discount_amount", 0.3225806451612903], ["total_software_value", "last_completed_year_active_invoice_amount", 0.36065573770491804], ["total_software_value", "last_completed_year_active_membership_count", 0.2857142857142857], ["total_software_value", "last_completed_year_active_membership_revenue", 0.3384615384615385], ["total_software_value", "lf_project_activities", 0.14634146341463414], ["total_software_value", "main_branch_commits", 0.10256410256410256], ["total_software_value", "membership_revenue", 0.3157894736842105], ["total_software_value", "past_event_speakers", 0.3076923076923077], ["total_software_value", "past_events_count", 0.16216216216216217], ["total_software_value", "project_count", 0.18181818181818182], ["total_software_value", "project_health_count", 0.3], ["total_software_value", "renewal_price", 0.30303030303030304], ["total_software_value", "sponsorship_quantity_total", 0.21739130434782608], ["total_software_value", "total_accepted_proposals", 0.4090909090909091], ["total_software_value", "total_activities", 0.3888888888888889], ["total_software_value", "total_certifications", 0.35], ["total_software_value", "total_code_deletions", 0.55], ["total_software_value", "total_code_insertions", 0.4878048780487805], ["total_software_value", "total_contributing_organizations", 0.2692307692307692], ["total_software_value", "total_contributors", 0.3684210526315789], ["total_software_value", "total_discount_amount", 0.5365853658536586], ["total_software_value", "total_downgrade_churn_amount", 0.5416666666666666], ["total_software_value", "total_enrolled_users", 0.4], ["total_software_value", "total_enrollments", 0.3783783783783784], ["total_software_value", "total_estimated_cost", 0.45], ["total_software_value", "total_event_registrations_goal", 0.48], ["total_software_value", "total_events", 0.4375], ["total_software_value", "total_first_time_contributors", 0.4489795918367347], ["total_software_value", "total_gross_revenue", 0.6153846153846154], ["total_software_value", "total_invoice_amount", 0.55], ["total_software_value", "total_maintainer_records", 0.45454545454545453], ["total_software_value", "total_maintainers", 0.3783783783783784], ["total_software_value", "total_next_membership_revenue", 0.4897959183673469], ["total_software_value", "total_registration_net_revenue", 0.48], ["total_software_value", "total_registration_tax", 0.47619047619047616], ["total_software_value", "total_registrations", 0.46153846153846156], ["total_software_value", "total_software_value", 1.0], ["total_software_value", "total_speakers", 0.5294117647058824], ["total_software_value", "total_speaking_engagements", 0.34782608695652173], ["total_software_value", "total_sponsorship_count", 0.4186046511627907], ["total_software_value", "total_sponsorship_revenue", 0.5777777777777777], ["total_software_value", "training_enrollments", 0.2], ["total_software_value", "upcoming_events_count", 0.14634146341463414], ["total_speakers", "active_maintainer_records", 0.3076923076923077], ["total_speakers", "active_maintainers", 0.375], ["total_speakers", "approved_pull_requests", 0.2777777777777778], ["total_speakers", "avg_project_health_score", 0.3157894736842105], ["total_speakers", "bot_activities", 0.2857142857142857], ["total_speakers", "certification_enrollments", 0.15384615384615385], ["total_speakers", "churned_membership_count", 0.2631578947368421], ["total_speakers", "churned_membership_discount_amount", 0.20833333333333334], ["total_speakers", "churned_membership_invoice_amount", 0.2127659574468085], ["total_speakers", "code_contribution_activities", 0.19047619047619047], ["total_speakers", "current_membership_count", 0.3157894736842105], ["total_speakers", "current_membership_discount_amount", 0.25], ["total_speakers", "current_membership_invoice_amount", 0.2553191489361702], ["total_speakers", "current_membership_revenue", 0.3], ["total_speakers", "current_new_account_membership_count", 0.32], ["total_speakers", "human_activities", 0.2], ["total_speakers", "last_completed_year_active_discount_amount", 0.2857142857142857], ["total_speakers", "last_completed_year_active_invoice_amount", 0.2545454545454545], ["total_speakers", "last_completed_year_active_membership_count", 0.3157894736842105], ["total_speakers", "last_completed_year_active_membership_revenue", 0.3050847457627119], ["total_speakers", "lf_project_activities", 0.17142857142857143], ["total_speakers", "main_branch_commits", 0.12121212121212122], ["total_speakers", "membership_revenue", 0.25], ["total_speakers", "past_event_speakers", 0.6666666666666666], ["total_speakers", "past_events_count", 0.1935483870967742], ["total_speakers", "project_count", 0.2222222222222222], ["total_speakers", "project_health_count", 0.23529411764705882], ["total_speakers", "renewal_price", 0.37037037037037035], ["total_speakers", "sponsorship_quantity_total", 0.25], ["total_speakers", "total_accepted_proposals", 0.47368421052631576], ["total_speakers", "total_activities", 0.4666666666666667], ["total_speakers", "total_certifications", 0.5294117647058824], ["total_speakers", "total_code_deletions", 0.4117647058823529], ["total_speakers", "total_code_insertions", 0.5714285714285714], ["total_speakers", "total_contributing_organizations", 0.30434782608695654], ["total_speakers", "total_contributors", 0.5], ["total_speakers", "total_discount_amount", 0.45714285714285713], ["total_speakers", "total_downgrade_churn_amount", 0.38095238095238093], ["total_speakers", "total_enrolled_users", 0.5882352941176471], ["total_speakers", "total_enrollments", 0.45161290322580644], ["total_speakers", "total_estimated_cost", 0.5294117647058824], ["total_speakers", "total_event_registrations_goal", 0.4090909090909091], ["total_speakers", "total_events", 0.5384615384615384], ["total_speakers", "total_first_time_contributors", 0.37209302325581395], ["total_speakers", "total_gross_revenue", 0.5454545454545454], ["total_speakers", "total_invoice_amount", 0.47058823529411764], ["total_speakers", "total_maintainer_records", 0.5263157894736842], ["total_speakers", "total_maintainers", 0.6451612903225806], ["total_speakers", "total_next_membership_revenue", 0.46511627906976744], ["total_speakers", "total_registration_net_revenue", 0.4090909090909091], ["total_speakers", "total_registration_tax", 0.4444444444444444], ["total_speakers", "total_registrations", 0.5454545454545454], ["total_speakers", "total_software_value", 0.5882352941176471], ["total_speakers", "total_speakers", 1.0], ["total_speakers", "total_speaking_engagements", 0.65], ["total_speakers", "total_sponsorship_count", 0.5405405405405406], ["total_speakers", "total_sponsorship_revenue", 0.5128205128205128], ["total_speakers", "training_enrollments", 0.23529411764705882], ["total_speakers", "upcoming_events_count", 0.17142857142857143], ["total_speaking_engagements", "active_maintainer_records", 0.3137254901960784], ["total_speaking_engagements", "active_maintainers", 0.3181818181818182], ["total_speaking_engagements", "approved_pull_requests", 0.25], ["total_speaking_engagements", "avg_project_health_score", 0.24], ["total_speaking_engagements", "bot_activities", 0.2], ["total_speaking_engagements", "certification_enrollments", 0.43137254901960786], ["total_speaking_engagements", "churned_membership_count", 0.24], ["total_speaking_engagements", "churned_membership_discount_amount", 0.2], ["total_speaking_engagements", "churned_membership_invoice_amount", 0.3389830508474576], ["total_speaking_engagements", "code_contribution_activities", 0.18518518518518517], ["total_speaking_engagements", "current_membership_count", 0.16], ["total_speaking_engagements", "current_membership_discount_amount", 0.13333333333333333], ["total_speaking_engagements", "current_membership_invoice_amount", 0.13559322033898305], ["total_speaking_engagements", "current_membership_revenue", 0.15384615384615385], ["total_speaking_engagements", "current_new_account_membership_count", 0.12903225806451613], ["total_speaking_engagements", "human_activities", 0.14285714285714285], ["total_speaking_engagements", "last_completed_year_active_discount_amount", 0.29411764705882354], ["total_speaking_engagements", "last_completed_year_active_invoice_amount", 0.3880597014925373], ["total_speaking_engagements", "last_completed_year_active_membership_count", 0.37681159420289856], ["total_speaking_engagements", "last_completed_year_active_membership_revenue", 0.30985915492957744], ["total_speaking_engagements", "lf_project_activities", 0.1276595744680851], ["total_speaking_engagements", "main_branch_commits", 0.35555555555555557], ["total_speaking_engagements", "membership_revenue", 0.22727272727272727], ["total_speaking_engagements", "past_event_speakers", 0.4444444444444444], ["total_speaking_engagements", "past_events_count", 0.32558139534883723], ["total_speaking_engagements", "project_count", 0.20512820512820512], ["total_speaking_engagements", "project_health_count", 0.2608695652173913], ["total_speaking_engagements", "renewal_price", 0.2564102564102564], ["total_speaking_engagements", "sponsorship_quantity_total", 0.19230769230769232], ["total_speaking_engagements", "total_accepted_proposals", 0.36], ["total_speaking_engagements", "total_activities", 0.3333333333333333], ["total_speaking_engagements", "total_certifications", 0.30434782608695654], ["total_speaking_engagements", "total_code_deletions", 0.30434782608695654], ["total_speaking_engagements", "total_code_insertions", 0.5106382978723404], ["total_speaking_engagements", "total_contributing_organizations", 0.5172413793103449], ["total_speaking_engagements", "total_contributors", 0.4090909090909091], ["total_speaking_engagements", "total_discount_amount", 0.3829787234042553], ["total_speaking_engagements", "total_downgrade_churn_amount", 0.5185185185185185], ["total_speaking_engagements", "total_enrolled_users", 0.4782608695652174], ["total_speaking_engagements", "total_enrollments", 0.6046511627906976], ["total_speaking_engagements", "total_estimated_cost", 0.43478260869565216], ["total_speaking_engagements", "total_event_registrations_goal", 0.39285714285714285], ["total_speaking_engagements", "total_events", 0.5789473684210527], ["total_speaking_engagements", "total_first_time_contributors", 0.4727272727272727], ["total_speaking_engagements", "total_gross_revenue", 0.4888888888888889], ["total_speaking_engagements", "total_invoice_amount", 0.5652173913043478], ["total_speaking_engagements", "total_maintainer_records", 0.48], ["total_speaking_engagements", "total_maintainers", 0.5116279069767442], ["total_speaking_engagements", "total_next_membership_revenue", 0.43636363636363634], ["total_speaking_engagements", "total_registration_net_revenue", 0.42857142857142855], ["total_speaking_engagements", "total_registration_tax", 0.5], ["total_speaking_engagements", "total_registrations", 0.4888888888888889], ["total_speaking_engagements", "total_software_value", 0.43478260869565216], ["total_speaking_engagements", "total_speakers", 0.65], ["total_speaking_engagements", "total_speaking_engagements", 1.0], ["total_speaking_engagements", "total_sponsorship_count", 0.4897959183673469], ["total_speaking_engagements", "total_sponsorship_revenue", 0.47058823529411764], ["total_speaking_engagements", "training_enrollments", 0.5652173913043478], ["total_speaking_engagements", "upcoming_events_count", 0.425531914893617], ["total_sponsorship_count", "active_maintainer_records", 0.2916666666666667], ["total_sponsorship_count", "active_maintainers", 0.2926829268292683], ["total_sponsorship_count", "approved_pull_requests", 0.2222222222222222], ["total_sponsorship_count", "avg_project_health_score", 0.2978723404255319], ["total_sponsorship_count", "bot_activities", 0.21621621621621623], ["total_sponsorship_count", "certification_enrollments", 0.2916666666666667], ["total_sponsorship_count", "churned_membership_count", 0.5106382978723404], ["total_sponsorship_count", "churned_membership_discount_amount", 0.42105263157894735], ["total_sponsorship_count", "churned_membership_invoice_amount", 0.42857142857142855], ["total_sponsorship_count", "code_contribution_activities", 0.23529411764705882], ["total_sponsorship_count", "current_membership_count", 0.5531914893617021], ["total_sponsorship_count", "current_membership_discount_amount", 0.45614035087719296], ["total_sponsorship_count", "current_membership_invoice_amount", 0.4642857142857143], ["total_sponsorship_count", "current_membership_revenue", 0.3673469387755102], ["total_sponsorship_count", "current_new_account_membership_count", 0.5084745762711864], ["total_sponsorship_count", "human_activities", 0.15384615384615385], ["total_sponsorship_count", "last_completed_year_active_discount_amount", 0.3384615384615385], ["total_sponsorship_count", "last_completed_year_active_invoice_amount", 0.21875], ["total_sponsorship_count", "last_completed_year_active_membership_count", 0.48484848484848486], ["total_sponsorship_count", "last_completed_year_active_membership_revenue", 0.35294117647058826], ["total_sponsorship_count", "lf_project_activities", 0.13636363636363635], ["total_sponsorship_count", "main_branch_commits", 0.38095238095238093], ["total_sponsorship_count", "membership_revenue", 0.34146341463414637], ["total_sponsorship_count", "past_event_speakers", 0.3333333333333333], ["total_sponsorship_count", "past_events_count", 0.45], ["total_sponsorship_count", "project_count", 0.3888888888888889], ["total_sponsorship_count", "project_health_count", 0.46511627906976744], ["total_sponsorship_count", "renewal_price", 0.3888888888888889], ["total_sponsorship_count", "sponsorship_quantity_total", 0.6122448979591837], ["total_sponsorship_count", "total_accepted_proposals", 0.425531914893617], ["total_sponsorship_count", "total_activities", 0.358974358974359], ["total_sponsorship_count", "total_certifications", 0.4186046511627907], ["total_sponsorship_count", "total_code_deletions", 0.4186046511627907], ["total_sponsorship_count", "total_code_insertions", 0.45454545454545453], ["total_sponsorship_count", "total_contributing_organizations", 0.32727272727272727], ["total_sponsorship_count", "total_contributors", 0.5365853658536586], ["total_sponsorship_count", "total_discount_amount", 0.5454545454545454], ["total_sponsorship_count", "total_downgrade_churn_amount", 0.5882352941176471], ["total_sponsorship_count", "total_enrolled_users", 0.4186046511627907], ["total_sponsorship_count", "total_enrollments", 0.45], ["total_sponsorship_count", "total_estimated_cost", 0.5581395348837209], ["total_sponsorship_count", "total_event_registrations_goal", 0.41509433962264153], ["total_sponsorship_count", "total_events", 0.45714285714285713], ["total_sponsorship_count", "total_first_time_contributors", 0.46153846153846156], ["total_sponsorship_count", "total_gross_revenue", 0.42857142857142855], ["total_sponsorship_count", "total_invoice_amount", 0.6046511627906976], ["total_sponsorship_count", "total_maintainer_records", 0.425531914893617], ["total_sponsorship_count", "total_maintainers", 0.45], ["total_sponsorship_count", "total_next_membership_revenue", 0.5384615384615384], ["total_sponsorship_count", "total_registration_net_revenue", 0.41509433962264153], ["total_sponsorship_count", "total_registration_tax", 0.4888888888888889], ["total_sponsorship_count", "total_registrations", 0.47619047619047616], ["total_sponsorship_count", "total_software_value", 0.5116279069767442], ["total_sponsorship_count", "total_speakers", 0.5405405405405406], ["total_sponsorship_count", "total_speaking_engagements", 0.4897959183673469], ["total_sponsorship_count", "total_sponsorship_count", 1.0], ["total_sponsorship_count", "total_sponsorship_revenue", 0.7916666666666666], ["total_sponsorship_count", "training_enrollments", 0.23255813953488372], ["total_sponsorship_count", "upcoming_events_count", 0.36363636363636365], ["total_sponsorship_revenue", "active_maintainer_records", 0.32], ["total_sponsorship_revenue", "active_maintainers", 0.27906976744186046], ["total_sponsorship_revenue", "approved_pull_requests", 0.2978723404255319], ["total_sponsorship_revenue", "avg_project_health_score", 0.32653061224489793], ["total_sponsorship_revenue", "bot_activities", 0.20512820512820512], ["total_sponsorship_revenue", "certification_enrollments", 0.32], ["total_sponsorship_revenue", "churned_membership_count", 0.32653061224489793], ["total_sponsorship_revenue", "churned_membership_discount_amount", 0.3050847457627119], ["total_sponsorship_revenue", "churned_membership_invoice_amount", 0.3103448275862069], ["total_sponsorship_revenue", "code_contribution_activities", 0.18867924528301888], ["total_sponsorship_revenue", "current_membership_count", 0.3673469387755102], ["total_sponsorship_revenue", "current_membership_discount_amount", 0.3389830508474576], ["total_sponsorship_revenue", "current_membership_invoice_amount", 0.3448275862068966], ["total_sponsorship_revenue", "current_membership_revenue", 0.5882352941176471], ["total_sponsorship_revenue", "current_new_account_membership_count", 0.36065573770491804], ["total_sponsorship_revenue", "human_activities", 0.14634146341463414], ["total_sponsorship_revenue", "last_completed_year_active_discount_amount", 0.29850746268656714], ["total_sponsorship_revenue", "last_completed_year_active_invoice_amount", 0.30303030303030304], ["total_sponsorship_revenue", "last_completed_year_active_membership_count", 0.35294117647058826], ["total_sponsorship_revenue", "last_completed_year_active_membership_revenue", 0.5142857142857142], ["total_sponsorship_revenue", "lf_project_activities", 0.13043478260869565], ["total_sponsorship_revenue", "main_branch_commits", 0.09090909090909091], ["total_sponsorship_revenue", "membership_revenue", 0.6046511627906976], ["total_sponsorship_revenue", "past_event_speakers", 0.3181818181818182], ["total_sponsorship_revenue", "past_events_count", 0.3333333333333333], ["total_sponsorship_revenue", "project_count", 0.15789473684210525], ["total_sponsorship_revenue", "project_health_count", 0.26666666666666666], ["total_sponsorship_revenue", "renewal_price", 0.3684210526315789], ["total_sponsorship_revenue", "sponsorship_quantity_total", 0.5098039215686274], ["total_sponsorship_revenue", "total_accepted_proposals", 0.40816326530612246], ["total_sponsorship_revenue", "total_activities", 0.34146341463414637], ["total_sponsorship_revenue", "total_certifications", 0.4], ["total_sponsorship_revenue", "total_code_deletions", 0.4], ["total_sponsorship_revenue", "total_code_insertions", 0.43478260869565216], ["total_sponsorship_revenue", "total_contributing_organizations", 0.3157894736842105], ["total_sponsorship_revenue", "total_contributors", 0.5116279069767442], ["total_sponsorship_revenue", "total_discount_amount", 0.4782608695652174], ["total_sponsorship_revenue", "total_downgrade_churn_amount", 0.37735849056603776], ["total_sponsorship_revenue", "total_enrolled_users", 0.4], ["total_sponsorship_revenue", "total_enrollments", 0.42857142857142855], ["total_sponsorship_revenue", "total_estimated_cost", 0.4], ["total_sponsorship_revenue", "total_event_registrations_goal", 0.4], ["total_sponsorship_revenue", "total_events", 0.5405405405405406], ["total_sponsorship_revenue", "total_first_time_contributors", 0.4444444444444444], ["total_sponsorship_revenue", "total_gross_revenue", 0.7272727272727273], ["total_sponsorship_revenue", "total_invoice_amount", 0.35555555555555557], ["total_sponsorship_revenue", "total_maintainer_records", 0.4489795918367347], ["total_sponsorship_revenue", "total_maintainers", 0.42857142857142855], ["total_sponsorship_revenue", "total_next_membership_revenue", 0.7407407407407407], ["total_sponsorship_revenue", "total_registration_net_revenue", 0.6181818181818182], ["total_sponsorship_revenue", "total_registration_tax", 0.425531914893617], ["total_sponsorship_revenue", "total_registrations", 0.45454545454545453], ["total_sponsorship_revenue", "total_software_value", 0.5777777777777777], ["total_sponsorship_revenue", "total_speakers", 0.5128205128205128], ["total_sponsorship_revenue", "total_speaking_engagements", 0.5098039215686274], ["total_sponsorship_revenue", "total_sponsorship_count", 0.7916666666666666], ["total_sponsorship_revenue", "total_sponsorship_revenue", 1.0], ["total_sponsorship_revenue", "training_enrollments", 0.26666666666666666], ["total_sponsorship_revenue", "upcoming_events_count", 0.30434782608695654], ["training_enrollments", "active_maintainer_records", 0.4444444444444444], ["training_enrollments", "active_maintainers", 0.47368421052631576], ["training_enrollments", "approved_pull_requests", 0.38095238095238093], ["training_enrollments", "avg_project_health_score", 0.3181818181818182], ["training_enrollments", "bot_activities", 0.35294117647058826], ["training_enrollments", "certification_enrollments", 0.7111111111111111], ["training_enrollments", "churned_membership_count", 0.3181818181818182], ["training_enrollments", "churned_membership_discount_amount", 0.25925925925925924], ["training_enrollments", "churned_membership_invoice_amount", 0.3018867924528302], ["training_enrollments", "code_contribution_activities", 0.2916666666666667], ["training_enrollments", "current_membership_count", 0.2727272727272727], ["training_enrollments", "current_membership_discount_amount", 0.2222222222222222], ["training_enrollments", "current_membership_invoice_amount", 0.22641509433962265], ["training_enrollments", "current_membership_revenue", 0.2608695652173913], ["training_enrollments", "current_new_account_membership_count", 0.21428571428571427], ["training_enrollments", "human_activities", 0.2777777777777778], ["training_enrollments", "last_completed_year_active_discount_amount", 0.25806451612903225], ["training_enrollments", "last_completed_year_active_invoice_amount", 0.32786885245901637], ["training_enrollments", "last_completed_year_active_membership_count", 0.2857142857142857], ["training_enrollments", "last_completed_year_active_membership_revenue", 0.27692307692307694], ["training_enrollments", "lf_project_activities", 0.2926829268292683], ["training_enrollments", "main_branch_commits", 0.3076923076923077], ["training_enrollments", "membership_revenue", 0.3157894736842105], ["training_enrollments", "past_event_speakers", 0.358974358974359], ["training_enrollments", "past_events_count", 0.3783783783783784], ["training_enrollments", "project_count", 0.30303030303030304], ["training_enrollments", "project_health_count", 0.25], ["training_enrollments", "renewal_price", 0.30303030303030304], ["training_enrollments", "sponsorship_quantity_total", 0.17391304347826086], ["training_enrollments", "total_accepted_proposals", 0.36363636363636365], ["training_enrollments", "total_activities", 0.3333333333333333], ["training_enrollments", "total_certifications", 0.3], ["training_enrollments", "total_code_deletions", 0.25], ["training_enrollments", "total_code_insertions", 0.34146341463414637], ["training_enrollments", "total_contributing_organizations", 0.4230769230769231], ["training_enrollments", "total_contributors", 0.2631578947368421], ["training_enrollments", "total_discount_amount", 0.2926829268292683], ["training_enrollments", "total_downgrade_churn_amount", 0.3333333333333333], ["training_enrollments", "total_enrolled_users", 0.55], ["training_enrollments", "total_enrollments", 0.7567567567567568], ["training_enrollments", "total_estimated_cost", 0.3], ["training_enrollments", "total_event_registrations_goal", 0.32], ["training_enrollments", "total_events", 0.5], ["training_enrollments", "total_first_time_contributors", 0.20408163265306123], ["training_enrollments", "total_gross_revenue", 0.3076923076923077], ["training_enrollments", "total_invoice_amount", 0.45], ["training_enrollments", "total_maintainer_records", 0.45454545454545453], ["training_enrollments", "total_maintainers", 0.4864864864864865], ["training_enrollments", "total_next_membership_revenue", 0.2857142857142857], ["training_enrollments", "total_registration_net_revenue", 0.4], ["training_enrollments", "total_registration_tax", 0.3333333333333333], ["training_enrollments", "total_registrations", 0.3076923076923077], ["training_enrollments", "total_software_value", 0.2], ["training_enrollments", "total_speakers", 0.17647058823529413], ["training_enrollments", "total_speaking_engagements", 0.5652173913043478], ["training_enrollments", "total_sponsorship_count", 0.32558139534883723], ["training_enrollments", "total_sponsorship_revenue", 0.3111111111111111], ["training_enrollments", "training_enrollments", 1.0], ["training_enrollments", "upcoming_events_count", 0.43902439024390244], ["upcoming_events_count", "active_maintainer_records", 0.13043478260869565], ["upcoming_events_count", "active_maintainers", 0.3076923076923077], ["upcoming_events_count", "approved_pull_requests", 0.27906976744186046], ["upcoming_events_count", "avg_project_health_score", 0.17777777777777778], ["upcoming_events_count", "bot_activities", 0.22857142857142856], ["upcoming_events_count", "certification_enrollments", 0.391304347826087], ["upcoming_events_count", "churned_membership_count", 0.35555555555555557], ["upcoming_events_count", "churned_membership_discount_amount", 0.32727272727272727], ["upcoming_events_count", "churned_membership_invoice_amount", 0.3333333333333333], ["upcoming_events_count", "code_contribution_activities", 0.32653061224489793], ["upcoming_events_count", "current_membership_count", 0.4888888888888889], ["upcoming_events_count", "current_membership_discount_amount", 0.4], ["upcoming_events_count", "current_membership_invoice_amount", 0.4074074074074074], ["upcoming_events_count", "current_membership_revenue", 0.3404255319148936], ["upcoming_events_count", "current_new_account_membership_count", 0.38596491228070173], ["upcoming_events_count", "human_activities", 0.2702702702702703], ["upcoming_events_count", "last_completed_year_active_discount_amount", 0.38095238095238093], ["upcoming_events_count", "last_completed_year_active_invoice_amount", 0.3225806451612903], ["upcoming_events_count", "last_completed_year_active_membership_count", 0.40625], ["upcoming_events_count", "last_completed_year_active_membership_revenue", 0.30303030303030304], ["upcoming_events_count", "lf_project_activities", 0.23809523809523808], ["upcoming_events_count", "main_branch_commits", 0.3], ["upcoming_events_count", "membership_revenue", 0.358974358974359], ["upcoming_events_count", "past_event_speakers", 0.4], ["upcoming_events_count", "past_events_count", 0.7368421052631579], ["upcoming_events_count", "project_count", 0.5294117647058824], ["upcoming_events_count", "project_health_count", 0.5365853658536586], ["upcoming_events_count", "renewal_price", 0.23529411764705882], ["upcoming_events_count", "sponsorship_quantity_total", 0.2553191489361702], ["upcoming_events_count", "total_accepted_proposals", 0.13333333333333333], ["upcoming_events_count", "total_activities", 0.21621621621621623], ["upcoming_events_count", "total_certifications", 0.2926829268292683], ["upcoming_events_count", "total_code_deletions", 0.2926829268292683], ["upcoming_events_count", "total_code_insertions", 0.3333333333333333], ["upcoming_events_count", "total_contributing_organizations", 0.33962264150943394], ["upcoming_events_count", "total_contributors", 0.358974358974359], ["upcoming_events_count", "total_discount_amount", 0.38095238095238093], ["upcoming_events_count", "total_downgrade_churn_amount", 0.40816326530612246], ["upcoming_events_count", "total_enrolled_users", 0.24390243902439024], ["upcoming_events_count", "total_enrollments", 0.3684210526315789], ["upcoming_events_count", "total_estimated_cost", 0.3902439024390244], ["upcoming_events_count", "total_event_registrations_goal", 0.39215686274509803], ["upcoming_events_count", "total_events", 0.48484848484848486], ["upcoming_events_count", "total_first_time_contributors", 0.32], ["upcoming_events_count", "total_gross_revenue", 0.4], ["upcoming_events_count", "total_invoice_amount", 0.3902439024390244], ["upcoming_events_count", "total_maintainer_records", 0.13333333333333333], ["upcoming_events_count", "total_maintainers", 0.3157894736842105], ["upcoming_events_count", "total_next_membership_revenue", 0.28], ["upcoming_events_count", "total_registration_net_revenue", 0.35294117647058826], ["upcoming_events_count", "total_registration_tax", 0.23255813953488372], ["upcoming_events_count", "total_registrations", 0.2], ["upcoming_events_count", "total_software_value", 0.0975609756097561], ["upcoming_events_count", "total_speakers", 0.22857142857142856], ["upcoming_events_count", "total_speaking_engagements", 0.425531914893617], ["upcoming_events_count", "total_sponsorship_count", 0.4090909090909091], ["upcoming_events_count", "total_sponsorship_revenue", 0.391304347826087], ["upcoming_events_count", "training_enrollments", 0.43902439024390244], ["upcoming_events_count", "upcoming_events_count", 1.0], ["_a", "_bbaa_b", 0.4444444444444444], ["ab", "abxab", 0.5714285714285714], ["xabc", "abcxYabc", 0.5], ["tide", "diet", 0.25], ["ca_bxab", "cab___cbcb", 0.5882352941176471], ["x_xbbb", "xy_b_y", 0.5], ["", "", 1.0], ["a", "", 0.0], ["", "a", 0.0]] \ No newline at end of file diff --git a/internal/tools/csv.go b/internal/tools/csv.go index 9842a14..8c2a5ca 100644 --- a/internal/tools/csv.go +++ b/internal/tools/csv.go @@ -1,6 +1,7 @@ // Copyright The Linux Foundation and contributors. // SPDX-License-Identifier: MIT +// Package tools provides MCP tool implementations for the LFX MCP server. package tools import ( diff --git a/internal/tools/semanticlayer.go b/internal/tools/semanticlayer.go index 60fc6d3..4c41a78 100644 --- a/internal/tools/semanticlayer.go +++ b/internal/tools/semanticlayer.go @@ -1,6 +1,7 @@ // Copyright The Linux Foundation and contributors. // SPDX-License-Identifier: MIT +// Package tools provides MCP tool implementations for the LFX MCP server. package tools import ( @@ -441,6 +442,13 @@ func handleQuerySemanticLayer(ctx context.Context, _ *mcp.CallToolRequest, args if args.Limit > maxQueryLimit { return toolError(fmt.Sprintf("Error: limit must be %d or less", maxQueryLimit)) } + // An omitted or negative limit means "no limit" to the API, which returns + // one result page and reports its length as the row count: 1024 rows + // presented as the whole answer. Default to the ceiling this tool + // advertises so the documented cap is the one that actually applies. + if args.Limit < 1 { + args.Limit = maxQueryLimit + } if disallowed := dbtsl.ValidateMetrics(metrics); len(disallowed) > 0 { return toolError(dbtsl.UnknownMetricsDetail(disallowed)) } diff --git a/internal/tools/semanticlayer_test.go b/internal/tools/semanticlayer_test.go index 92862a3..d093390 100644 --- a/internal/tools/semanticlayer_test.go +++ b/internal/tools/semanticlayer_test.go @@ -317,6 +317,36 @@ func TestSemanticLayerLimitTooLarge(t *testing.T) { } } +// TestSemanticLayerDefaultsTheLimitToTheAdvertisedCeiling: an omitted or +// negative limit reaches the API as "no limit", which returns one result page +// and reports its length as the row count — 1024 rows presented as the whole +// answer. The tool description promises a ceiling of 500, so that is what an +// unspecified limit has to mean. +func TestSemanticLayerDefaultsTheLimitToTheAdvertisedCeiling(t *testing.T) { + for _, limit := range []int{0, -1} { + captured := setupSemanticLayerTest(t) + + res, _, err := handleQuerySemanticLayer(context.Background(), &mcp.CallToolRequest{}, QuerySemanticLayerArgs{ + Metrics: "active_maintainers", + Limit: limit, + }) + if err != nil { + t.Fatalf("limit %d: unexpected error: %v", limit, err) + } + if res.IsError { + t.Fatalf("limit %d: unexpected error result: %s", limit, resultText(t, res)) + } + + vars, called := captured.operation("CreateQuery") + if !called { + t.Fatalf("limit %d: expected a query to be created", limit) + } + if got := vars["limit"]; got != float64(maxQueryLimit) { + t.Errorf("limit %d: expected the query capped at %d, got %v", limit, maxQueryLimit, got) + } + } +} + // TestSemanticLayerHelpQueryDescribesWhereScoping checks the help text moved // off the removed scope parameter and onto the where clause. func TestSemanticLayerHelpQueryDescribesWhereScoping(t *testing.T) { From 2297f2938a0b16579583d8d231019e4d0c643ed0 Mon Sep 17 00:00:00 2001 From: Josep Garcia-Reyero Sais Date: Fri, 31 Jul 2026 17:57:33 +0200 Subject: [PATCH 5/8] fix(dbtsl): unshadow close, and page the metadata queries revive rejects the branch: allowlist.go named a local slice 'close', shadowing the builtin. This is what was failing MegaLinter, not the jscpd duplication report, which .mega-linter.yml lists under DISABLE_ERRORS_LINTERS and is non-blocking. Renamed to 'closest'. metricsPaginated and dimensionsPaginated were read a page at a time with no check that a page was all there was. That is the same defect just fixed for query results, and it was inherited from the Python rather than introduced, but the consequence here is a partial allowlist: metrics that exist reported as unknown, and a suggested topic search returning nothing. FetchDimensions is worse, since FetchDimensionValues uses exactly that list to decide what may be read, so a missing dimension reads as one that does not exist. Both now follow totalPages. Live, both report totalPages 1 today, with all 295 dimensions on a single page, so this changes no current behaviour and only removes the assumption. Issue: LFXV2-2940 Signed-off-by: Josep Garcia-Reyero Sais --- internal/dbtsl/allowlist.go | 8 +-- internal/dbtsl/dbtsl_test.go | 52 ++++++++++++++++ internal/dbtsl/metadata.go | 112 +++++++++++++++++++++-------------- 3 files changed, 125 insertions(+), 47 deletions(-) diff --git a/internal/dbtsl/allowlist.go b/internal/dbtsl/allowlist.go index 9827b41..370b1b5 100644 --- a/internal/dbtsl/allowlist.go +++ b/internal/dbtsl/allowlist.go @@ -265,15 +265,15 @@ func SuggestMetrics(name string, limit int) []string { ratio float64 name string } - var close []ranked + var closest []ranked for _, metric := range AllowedMetricNames() { if r := similarityRatio(name, metric); r >= cutoff { - close = append(close, ranked{ratio: r, name: metric}) + closest = append(closest, ranked{ratio: r, name: metric}) } } - sort.SliceStable(close, func(i, j int) bool { return close[i].ratio > close[j].ratio }) + sort.SliceStable(closest, func(i, j int) bool { return closest[i].ratio > closest[j].ratio }) names := make([]string, 0, limit) - for _, c := range close { + for _, c := range closest { if len(names) == limit { break } diff --git a/internal/dbtsl/dbtsl_test.go b/internal/dbtsl/dbtsl_test.go index 802e90f..fc954b0 100644 --- a/internal/dbtsl/dbtsl_test.go +++ b/internal/dbtsl/dbtsl_test.go @@ -394,6 +394,58 @@ func TestParseQueryResultHandlesAnEmptyBody(t *testing.T) { } } +// TestFetchAllowedMetricsFollowsEveryPage: a partial metric list is a partial +// allowlist, so metrics that exist would be reported unknown and a suggested +// topic search would return nothing. One page holds every metric today, so +// this only guards the assumption. +func TestFetchAllowedMetricsFollowsEveryPage(t *testing.T) { + stub := newStubServer(t) + // Two metrics is under inlineDimensionsThreshold, so the fetch is repeated + // with dimensions inlined. Both operations have to page. + for _, op := range []string{"GetMetrics", "GetMetricsWithRelated"} { + stub.queue(op, `{"data":{"metricsPaginated":{"totalPages":2,"items":[ + {"name":"total_contributors","label":"Contributors","description":"","type":"SIMPLE"} + ]}}}`) + stub.queue(op, `{"data":{"metricsPaginated":{"totalPages":2,"items":[ + {"name":"active_maintainers","label":"Maintainers","description":"","type":"SIMPLE"} + ]}}}`) + } + + client := stub.client(t) + metrics, err := client.FetchAllowedMetrics(context.Background(), "") + if err != nil { + t.Fatalf("FetchAllowedMetrics failed: %v", err) + } + if len(metrics) != 2 { + t.Fatalf("expected both pages, got %d metrics: %v", len(metrics), metrics) + } + if got := stub.lastVariables()["pageNum"]; got != float64(2) { + t.Errorf("expected the second page requested, got pageNum %v", got) + } +} + +// TestFetchDimensionsFollowsEveryPage: FetchDimensionValues decides what may +// be read from exactly this list, so a dimension missing from it is one the +// caller is told does not exist. +func TestFetchDimensionsFollowsEveryPage(t *testing.T) { + stub := newStubServer(t) + stub.queue("GetDimensions", `{"data":{"dimensionsPaginated":{"totalPages":2,"items":[ + {"name":"country__lf_region","type":"categorical","description":"","label":"Region","queryableGranularities":[]} + ]}}}`) + stub.queue("GetDimensions", `{"data":{"dimensionsPaginated":{"totalPages":2,"items":[ + {"name":"country__country_name","type":"categorical","description":"","label":"Country","queryableGranularities":[]} + ]}}}`) + + client := stub.client(t) + dimensions, err := client.FetchDimensions(context.Background(), []string{"total_contributors"}) + if err != nil { + t.Fatalf("FetchDimensions failed: %v", err) + } + if len(dimensions) != 2 { + t.Fatalf("expected both pages, got %d dimensions: %v", len(dimensions), dimensions) + } +} + // --------------------------------------------------------------------------- // Query execution over the poll loop // --------------------------------------------------------------------------- diff --git a/internal/dbtsl/metadata.go b/internal/dbtsl/metadata.go index 10aca3b..72a57cb 100644 --- a/internal/dbtsl/metadata.go +++ b/internal/dbtsl/metadata.go @@ -16,8 +16,9 @@ import ( const inlineDimensionsThreshold = 15 const gqlMetrics = ` -query GetMetrics($environmentId: BigInt!, $search: String) { - metricsPaginated(environmentId: $environmentId, search: $search) { +query GetMetrics($environmentId: BigInt!, $search: String, $pageNum: Int!) { + metricsPaginated(environmentId: $environmentId, search: $search, pageNum: $pageNum) { + totalPages items { name label @@ -29,8 +30,9 @@ query GetMetrics($environmentId: BigInt!, $search: String) { ` const gqlMetricsWithRelated = ` -query GetMetricsWithRelated($environmentId: BigInt!, $search: String) { - metricsPaginated(environmentId: $environmentId, search: $search) { +query GetMetricsWithRelated($environmentId: BigInt!, $search: String, $pageNum: Int!) { + metricsPaginated(environmentId: $environmentId, search: $search, pageNum: $pageNum) { + totalPages items { name label @@ -48,8 +50,9 @@ query GetMetricsWithRelated($environmentId: BigInt!, $search: String) { ` const gqlDimensions = ` -query GetDimensions($environmentId: BigInt!, $metrics: [MetricInput!]!) { - dimensionsPaginated(environmentId: $environmentId, metrics: $metrics) { +query GetDimensions($environmentId: BigInt!, $metrics: [MetricInput!]!, $pageNum: Int!) { + dimensionsPaginated(environmentId: $environmentId, metrics: $metrics, pageNum: $pageNum) { + totalPages items { name type @@ -96,6 +99,7 @@ type metricsResponse struct { Name string `json:"name"` } `json:"entities"` } `json:"items"` + TotalPages int `json:"totalPages"` } `json:"metricsPaginated"` } @@ -109,6 +113,7 @@ type dimensionsResponse struct { Label string `json:"label"` QueryableGranularities []string `json:"queryableGranularities"` } `json:"items"` + TotalPages int `json:"totalPages"` } `json:"dimensionsPaginated"` } @@ -189,36 +194,47 @@ func filterAllowed(metrics []MetricInfo) []MetricInfo { // unfiltered. When includeDimensions is set each metric carries its available // dimension names, at the cost of an extra GraphQL field. func (c *Client) fetchMetricsRaw(ctx context.Context, search string, includeDimensions bool) ([]MetricInfo, error) { - variables := map[string]any{} - if search != "" { - variables["search"] = search - } - query := gqlMetrics if includeDimensions { query = gqlMetricsWithRelated } - var resp metricsResponse - if err := c.graphqlRequest(ctx, query, variables, &resp); err != nil { - return nil, err - } + var metrics []MetricInfo - metrics := make([]MetricInfo, 0, len(resp.MetricsPaginated.Items)) - for _, item := range resp.MetricsPaginated.Items { - metric := MetricInfo{ - Name: item.Name, - Label: item.Label, - Description: item.Description, - Type: item.Type, + // Metadata is paginated on the same terms as query results. One page holds + // every metric in this environment today, so this loop runs once, but a + // partial metric list is a partial allowlist: metrics that exist would + // report as unknown, and the caller would be told to search a topic that + // then returns nothing. Follow the pages rather than assume one. + for page, totalPages := 1, 1; page <= totalPages; page++ { + variables := map[string]any{"pageNum": page} + if search != "" { + variables["search"] = search } - for _, d := range item.Dimensions { - metric.Dimensions = append(metric.Dimensions, d.Name) + + var resp metricsResponse + if err := c.graphqlRequest(ctx, query, variables, &resp); err != nil { + return nil, err } - for _, e := range item.Entities { - metric.Entities = append(metric.Entities, e.Name) + if resp.MetricsPaginated.TotalPages > totalPages { + totalPages = resp.MetricsPaginated.TotalPages + } + + for _, item := range resp.MetricsPaginated.Items { + metric := MetricInfo{ + Name: item.Name, + Label: item.Label, + Description: item.Description, + Type: item.Type, + } + for _, d := range item.Dimensions { + metric.Dimensions = append(metric.Dimensions, d.Name) + } + for _, e := range item.Entities { + metric.Entities = append(metric.Entities, e.Name) + } + metrics = append(metrics, metric) } - metrics = append(metrics, metric) } return metrics, nil } @@ -238,24 +254,34 @@ func (c *Client) FetchDimensions(ctx context.Context, metricNames []string) ([]D metricInputs = append(metricInputs, map[string]string{"name": name}) } - var resp dimensionsResponse - if err := c.graphqlRequest(ctx, gqlDimensions, map[string]any{"metrics": metricInputs}, &resp); err != nil { - return nil, err - } + // Paginated for the same reason as the metric list: a dimension missing + // from this result is one the caller is told does not exist, and + // FetchDimensionValues uses exactly this list to decide what may be read. + var dimensions []DimensionInfo + for page, totalPages := 1, 1; page <= totalPages; page++ { + variables := map[string]any{"metrics": metricInputs, "pageNum": page} - dimensions := make([]DimensionInfo, 0, len(resp.DimensionsPaginated.Items)) - for _, item := range resp.DimensionsPaginated.Items { - granularities := item.QueryableGranularities - if granularities == nil { - granularities = []string{} + var resp dimensionsResponse + if err := c.graphqlRequest(ctx, gqlDimensions, variables, &resp); err != nil { + return nil, err + } + if resp.DimensionsPaginated.TotalPages > totalPages { + totalPages = resp.DimensionsPaginated.TotalPages + } + + for _, item := range resp.DimensionsPaginated.Items { + granularities := item.QueryableGranularities + if granularities == nil { + granularities = []string{} + } + dimensions = append(dimensions, DimensionInfo{ + Name: item.Name, + Type: item.Type, + Description: item.Description, + Label: item.Label, + QueryableTimeGranularities: granularities, + }) } - dimensions = append(dimensions, DimensionInfo{ - Name: item.Name, - Type: item.Type, - Description: item.Description, - Label: item.Label, - QueryableTimeGranularities: granularities, - }) } c.dimensionsCache.put(cacheKey, dimensions) From b16d98ce686ecc89e801ca49919879ee45711918 Mon Sep 17 00:00:00 2001 From: Josep Garcia-Reyero Sais Date: Fri, 31 Jul 2026 18:03:07 +0200 Subject: [PATCH 6/8] feat(tools): redirect slug lookups to search_projects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enumerating a slug dimension works but is the wrong tool for the job: the values come back capped and alphabetical, so the slug being looked for is usually not in the list, and it is slow to build (project_spine_slug measured 14.8s live against 1-2s for other dimensions). search_projects answers that question directly, by name. The guidance is a runtime note on the result rather than a line in the tool description. Description bytes are the scarcest resource on this surface — explore has 7 of its 2048 left, so this could only have been bought by deleting other guidance — and a note fires exactly when the mistake is made instead of being paid for on every call. It is emitted as its own content block so it cannot be read as part of the data, and it lives in internal/tools rather than internal/dbtsl, since the name of an MCP tool is not something the semantic layer client should know. pollMaxInterval stays at 2s. A 1s ceiling was considered: the cap only engages past about 3s, so it does nothing for the warm queries that return in 0.9-1.6s, and where it does engage it trades up to 1s of wall time for 10 extra requests on a 22.5s query — polling hardest exactly when the warehouse is slowest, to save 4%. Recorded next to the constant so it is not re-litigated. Also drops the lens test harness left dead by the lens.go split. It existed for the semantic layer HTTP tests, which now stub dbtsl directly. No coverage is lost: query_lfx_lens had no behavioural test before this branch either, only description assertions. Issue: LFXV2-2940 Signed-off-by: Josep Garcia-Reyero Sais --- internal/dbtsl/query.go | 6 +++ internal/tools/lens_test.go | 54 ------------------------ internal/tools/semanticlayer.go | 44 ++++++++++++++++++++ internal/tools/semanticlayer_test.go | 61 ++++++++++++++++++++++++++++ 4 files changed, 111 insertions(+), 54 deletions(-) diff --git a/internal/dbtsl/query.go b/internal/dbtsl/query.go index 0d8afe2..b9a361f 100644 --- a/internal/dbtsl/query.go +++ b/internal/dbtsl/query.go @@ -37,6 +37,12 @@ query GetQueryResult($environmentId: BigInt!, $queryId: String!, $pageNum: Int!) // Query polling cadence. The Semantic Layer compiles and runs warehouse SQL, // so the first result is rarely ready immediately. The interval backs off so a // slow query does not generate a poll storm. +// +// A 1s ceiling was considered and rejected. The cap only engages once a query +// passes about 3s, so for the common case (warm queries return in 0.9 to 1.6s +// here) it changes nothing at all. Where it does engage it would trade up to +// 1s of wall time against 10 extra requests on a 22.5s query — polling +// hardest exactly when the warehouse is already slowest, to save 4%. const ( pollInitialInterval = 250 * time.Millisecond pollMaxInterval = 2 * time.Second diff --git a/internal/tools/lens_test.go b/internal/tools/lens_test.go index 6a098ee..94557c6 100644 --- a/internal/tools/lens_test.go +++ b/internal/tools/lens_test.go @@ -6,66 +6,12 @@ package tools import ( "context" "encoding/json" - "io" - "net/http" - "net/http/httptest" - "net/url" "strings" "testing" - "github.com/linuxfoundation/lfx-mcp/internal/serviceapi" "github.com/modelcontextprotocol/go-sdk/mcp" ) -type stubTokenSource struct{} - -func (stubTokenSource) GetToken(_ context.Context) (string, error) { - return "test-token", nil -} - -// capturedLensRequest records the last request received by the stub lens API. -type capturedLensRequest struct { - Method string - Path string - Query url.Values - Body []byte -} - -// setupLensTest points the shared lensConfig at a stub lens API server that -// captures requests and returns a small JSON payload. The previous config is -// restored on test cleanup. Tests using this must not run in parallel because -// lensConfig is a package-level global. -func setupLensTest(t *testing.T) *capturedLensRequest { - t.Helper() - - captured := &capturedLensRequest{} - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - captured.Method = r.Method - captured.Path = r.URL.Path - captured.Query = r.URL.Query() - body, _ := io.ReadAll(r.Body) - captured.Body = body - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{"ok": true}`)) - })) - t.Cleanup(srv.Close) - - client, err := serviceapi.NewClient(serviceapi.Config{ - BaseURL: srv.URL, - TokenSource: stubTokenSource{}, - }) - if err != nil { - t.Fatalf("failed to create service API client: %v", err) - } - - prev := lensConfig - SetLensConfig(&LensConfig{ServiceClient: client}) - t.Cleanup(func() { lensConfig = prev }) - - return captured -} - func resultText(t *testing.T, res *mcp.CallToolResult) string { t.Helper() if res == nil || len(res.Content) == 0 { diff --git a/internal/tools/semanticlayer.go b/internal/tools/semanticlayer.go index 4c41a78..eba2806 100644 --- a/internal/tools/semanticlayer.go +++ b/internal/tools/semanticlayer.go @@ -427,9 +427,38 @@ func handleSLGetDimensionValues(ctx context.Context, dimension, metricsArg, sear if values.ValueCount == 0 { return toolError(dbtsl.NoDimensionValuesDetail(dimension, strings.TrimSpace(search))) } + + if note := slugLookupNote(dimension); note != "" { + return toolJSONWithNote(values, note) + } return toolJSON(values) } +// slugLookupNote redirects slug lookups to search_projects. +// +// Enumerating a slug dimension works, but it is the wrong tool for it: the +// values come back capped and alphabetical, so the slug being looked for is +// usually not among them, and the list is slow to build (project_spine_slug +// measured 14.8s live against 1-2s for other dimensions). search_projects +// answers the actual question, by name, directly. +// +// This is a runtime note rather than a line in the tool description on +// purpose. Description bytes are the scarcest resource here — the explore +// description has 7 bytes of its 2048 left, so this guidance could only be +// bought by deleting other guidance — and a note fires exactly when the +// mistake is made rather than being paid for on every call. dbtsl cannot host +// it either: the name of an MCP tool is not something the semantic layer +// client should know. +func slugLookupNote(dimension string) string { + if !strings.HasSuffix(strings.TrimSpace(dimension), "_slug") { + return "" + } + return "Note: to find a project or foundation by name, use search_projects instead — " + + "it looks the slug up directly. This list is capped and alphabetical, so a specific " + + "slug is often absent from it. Enumerating slugs here is only useful when you want " + + "a sample of what the dimension holds." +} + func handleQuerySemanticLayer(ctx context.Context, _ *mcp.CallToolRequest, args QuerySemanticLayerArgs) (*mcp.CallToolResult, any, error) { if semanticLayerConfig == nil || semanticLayerConfig.Client == nil { return nil, nil, fmt.Errorf("semantic layer tools not configured") @@ -499,3 +528,18 @@ func toolJSON(value any) (*mcp.CallToolResult, any, error) { } return toolText(string(pretty)) } + +// toolJSONWithNote returns a result alongside guidance, as a second content +// block so the note cannot be mistaken for part of the data. +func toolJSONWithNote(value any, note string) (*mcp.CallToolResult, any, error) { + pretty, err := json.MarshalIndent(value, "", " ") + if err != nil { + return nil, nil, fmt.Errorf("failed to encode semantic layer result: %w", err) + } + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{Text: string(pretty)}, + &mcp.TextContent{Text: note}, + }, + }, nil, nil +} diff --git a/internal/tools/semanticlayer_test.go b/internal/tools/semanticlayer_test.go index d093390..9f56656 100644 --- a/internal/tools/semanticlayer_test.go +++ b/internal/tools/semanticlayer_test.go @@ -347,6 +347,67 @@ func TestSemanticLayerDefaultsTheLimitToTheAdvertisedCeiling(t *testing.T) { } } +// TestSlugLookupNoteFiresOnlyOnSlugDimensions: the note redirects slug +// lookups to search_projects, which answers by name directly. It must not +// attach to ordinary dimensions, where it would be noise on every call. +func TestSlugLookupNoteFiresOnlyOnSlugDimensions(t *testing.T) { + for _, dimension := range []string{ + "registration_id__project_slug", + "account_project_month_id__project_slug", + " project_spine_slug ", + } { + if slugLookupNote(dimension) == "" { + t.Errorf("expected a note for %q", dimension) + } + } + for _, dimension := range []string{ + "country__lf_region", + "country__country_name", + "asset_id__membership_tier", + "activity_project_id__organization_name", + // Guards against matching on "slug" anywhere in the name. + "health_metric_key__slug_status", + } { + if note := slugLookupNote(dimension); note != "" { + t.Errorf("expected no note for %q, got %q", dimension, note) + } + } +} + +// The note has to reach the caller as its own content block, so it cannot be +// read as part of the value list. +func TestSemanticLayerDimensionValuesCarriesTheSlugNote(t *testing.T) { + prev := stubResponses["GetDimensions"] + stubResponses["GetDimensions"] = `{"data":{"dimensionsPaginated":{"items":[ + {"name":"registration_id__project_slug","type":"categorical","description":"Slug","label":"Slug","queryableGranularities":[]} + ]}}}` + t.Cleanup(func() { stubResponses["GetDimensions"] = prev }) + + setupSemanticLayerTest(t) + + res, _, err := handleExploreSemanticLayer(context.Background(), &mcp.CallToolRequest{}, ExploreSemanticLayerArgs{ + Action: "get_dimension_values", + Dimension: "registration_id__project_slug", + Metrics: "total_contributors", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if res.IsError { + t.Fatalf("unexpected error result: %s", resultText(t, res)) + } + if len(res.Content) != 2 { + t.Fatalf("expected the values and the note as separate blocks, got %d", len(res.Content)) + } + note, ok := res.Content[1].(*mcp.TextContent) + if !ok { + t.Fatalf("expected a text block, got %T", res.Content[1]) + } + if !strings.Contains(note.Text, "search_projects") { + t.Errorf("expected the note to name search_projects, got %q", note.Text) + } +} + // TestSemanticLayerHelpQueryDescribesWhereScoping checks the help text moved // off the removed scope parameter and onto the where clause. func TestSemanticLayerHelpQueryDescribesWhereScoping(t *testing.T) { From dc365c4dd6312ad01936f5ccad7e52d567e6ccbb Mon Sep 17 00:00:00 2001 From: Josep Garcia-Reyero Sais Date: Fri, 31 Jul 2026 18:19:46 +0200 Subject: [PATCH 7/8] test(dbtsl): make the exact-integer guard able to fail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The test guarding json.Number decoding passed with UseNumber removed, so it protected nothing. 4239559, the count actually observed in the wild, is exactly representable as a float64 and re-encodes as "4239559" with no exponent, so every assertion held either way. Uses 2^53+1 instead, which cannot survive a float64 round trip, and asserts the decoded type rather than inferring it from the rendering. Without UseNumber it now fails with 9.007199254740992e+15 — both the exponent and the lost +1. Same defect as the difflib test fixed earlier on this branch: a guard written from the observed symptom rather than the mechanism, passing in both directions. Issue: LFXV2-2940 Signed-off-by: Josep Garcia-Reyero Sais --- internal/dbtsl/dbtsl_test.go | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/internal/dbtsl/dbtsl_test.go b/internal/dbtsl/dbtsl_test.go index fc954b0..2144e8a 100644 --- a/internal/dbtsl/dbtsl_test.go +++ b/internal/dbtsl/dbtsl_test.go @@ -1042,20 +1042,35 @@ func contains(values []string, want string) bool { // TestParseQueryResultKeepsLargeIntegersExact guards against float64 decoding, // which would render a 4239559 contributor count as 4.239559e+06 by the time // the model reads it. +// The value here is deliberately above 2^53. The real observed case, a +// 4239559 contributor count, is exactly representable as a float64 and +// re-encodes as "4239559" with no exponent, so asserting on it passes just as +// well with UseNumber removed — the guard could not fail. This value cannot +// survive a float64 round trip, and the decoded type is asserted directly. func TestParseQueryResultKeepsLargeIntegersExact(t *testing.T) { + const exact = "9007199254740993" // 2^53 + 1 raw := `{"schema":{"fields":[{"name":"total_contributors","type":"integer"}],"primaryKey":[]}, - "data":[{"total_contributors":4239559}]}` + "data":[{"total_contributors":` + exact + `}]}` result, err := parseQueryResult(raw, "") if err != nil { t.Fatalf("parseQueryResult failed: %v", err) } + value := result.Data[0]["total_contributors"] + number, ok := value.(json.Number) + if !ok { + t.Fatalf("expected json.Number so digits survive verbatim, got %T (%v)", value, value) + } + if number.String() != exact { + t.Errorf("expected %s, got %s", exact, number) + } + encoded, err := json.Marshal(result.Data[0]) if err != nil { t.Fatalf("failed to re-encode the row: %v", err) } - if !strings.Contains(string(encoded), "4239559") { + if !strings.Contains(string(encoded), exact) { t.Errorf("expected the exact integer, got %s", encoded) } if strings.Contains(string(encoded), "e+") { From 2065510303f273e014b6e61880b97baba5e59008 Mon Sep 17 00:00:00 2001 From: Josep Garcia-Reyero Sais Date: Fri, 31 Jul 2026 18:29:14 +0200 Subject: [PATCH 8/8] fix(dbtsl): treat the dimension search as a literal search is documented as a plain substring, but % and _ went into the ILIKE pattern unescaped and stayed wildcards. Live, a search for "_" returned every country name in the environment and "%" did the same. That is the worst failure this action can have: it exists to hand back exact literals so a filter matches something, and a list of unrelated values reads as though those values matched. Metacharacters are now escaped and an ESCAPE clause accompanies the pattern. The escape character is '!', not the obvious backslash: backslash is also the escape character of the SQL string literal, so it is consumed during string parsing before ILIKE sees it. Verified live, a '%\_%' pattern matched Germany and Yemen. The unit tests could not have caught this, and the new one only pins the clause that gets built: a stub echoes whatever rows the test queued, so nothing about the pattern is actually evaluated. The test that matters is in the parity harness, where the real ILIKE runs and a literal search for "_" must return nothing. Issue: LFXV2-2940 Signed-off-by: Josep Garcia-Reyero Sais --- internal/dbtsl/dbtsl_test.go | 55 ++++++++++++++++++++++++++++++ internal/dbtsl/dimensionvalues.go | 29 +++++++++++++++- internal/dbtsl/parity_live_test.go | 36 +++++++++++++++++++ 3 files changed, 119 insertions(+), 1 deletion(-) diff --git a/internal/dbtsl/dbtsl_test.go b/internal/dbtsl/dbtsl_test.go index 2144e8a..c29dbfb 100644 --- a/internal/dbtsl/dbtsl_test.go +++ b/internal/dbtsl/dbtsl_test.go @@ -717,6 +717,61 @@ func TestFetchDimensionValuesRejectsADimensionTheMetricDoesNotExpose(t *testing. } } +// TestEscapeLikePatternNeutralisesWildcards: search is documented as a plain +// substring, but % and _ are ILIKE wildcards. Unescaped, a live search for +// "_" returned every country name in the environment. +func TestEscapeLikePatternNeutralisesWildcards(t *testing.T) { + tests := []struct{ in, want string }{ + {"viet", "viet"}, + {"_", `!_`}, + {"%", `!%`}, + {"100%", `100!%`}, + {"_unknown", `!_unknown`}, + {"a_b%c", `a!_b!%c`}, + // The escape character itself has to survive being searched for. + {"!", `!!`}, + {"a!_b", `a!!!_b`}, + // Quotes are not LIKE metacharacters; escapeSQLLiteral owns those. + {"d'Ivoire", "d'Ivoire"}, + } + for _, tc := range tests { + if got := escapeLikePattern(tc.in); got != tc.want { + t.Errorf("escapeLikePattern(%q) = %q, want %q", tc.in, got, tc.want) + } + } +} + +// The ESCAPE clause has to reach the query, or the escaping above turns the +// pattern into a search for literal "!_" instead. +func TestFetchDimensionValuesEscapesWildcardsInTheFilter(t *testing.T) { + stub := newStubServer(t) + stub.queue("GetDimensions", dimensionsForContributors) + stub.queue("CreateQuery", `{"data":{"createQuery":{"queryId":"q-1"}}}`) + stub.queue("GetQueryResult", `{"data":{"query":{"status":"SUCCESSFUL","error":null,"sql":"","jsonResult":"{\"schema\":{\"fields\":[{\"name\":\"country__country_name\",\"type\":\"string\"}],\"primaryKey\":[]},\"data\":[]}"}}}`) + + client := stub.client(t) + _, _ = client.FetchDimensionValues(context.Background(), + "country__country_name", []string{"total_contributors"}, "_", 100) + + var createVars map[string]any + for _, req := range stub.requests { + if query, _ := req["query"].(string); operationOf(query) == "CreateQuery" { + createVars, _ = req["variables"].(map[string]any) + } + } + where, _ := createVars["where"].([]any) + if len(where) != 1 { + t.Fatalf("expected one where clause, got %v", createVars["where"]) + } + clause, _ := where[0].(map[string]any)["sql"].(string) + if !strings.Contains(clause, `'%!_%'`) { + t.Errorf("expected the underscore escaped in the pattern, got %q", clause) + } + if !strings.Contains(clause, `ESCAPE '!'`) { + t.Errorf("expected an ESCAPE clause, got %q", clause) + } +} + func TestFetchDimensionValuesBuildsAnILIKEFilter(t *testing.T) { stub := newStubServer(t) stub.queue("GetDimensions", dimensionsForContributors) diff --git a/internal/dbtsl/dimensionvalues.go b/internal/dbtsl/dimensionvalues.go index 1630a10..0d10631 100644 --- a/internal/dbtsl/dimensionvalues.go +++ b/internal/dbtsl/dimensionvalues.go @@ -56,6 +56,30 @@ func escapeSQLLiteral(value string) string { return strings.ReplaceAll(strings.ReplaceAll(value, `\`, `\\`), `'`, `''`) } +// likeEscapeChar is the ESCAPE character for the search pattern. +// +// Backslash is the obvious choice and the wrong one: it is also the escape +// character of the SQL string literal itself, so it is consumed during string +// parsing before ILIKE ever sees it. Verified live — a '%\_%' pattern matched +// Germany and Yemen, which contain no underscore. '!' has no meaning inside a +// string literal, so what is written is what ILIKE receives. +const likeEscapeChar = "!" + +// escapeLikePattern makes value match literally inside an ILIKE pattern. +// +// The search argument is documented as a plain substring, but % and _ are +// ILIKE wildcards. Unescaped, a search for "_" matched every country name in +// the environment and "%" matched everything, which is the worst failure this +// tool can have: it exists to hand back exact literals, and a list of +// unrelated values reads as though those values matched. +// +// The escape character is escaped first, so an input containing it survives. +func escapeLikePattern(value string) string { + value = strings.ReplaceAll(value, likeEscapeChar, likeEscapeChar+likeEscapeChar) + value = strings.ReplaceAll(value, "%", likeEscapeChar+"%") + return strings.ReplaceAll(value, "_", likeEscapeChar+"_") +} + // FetchDimensionValues returns the distinct values of a dimension, so a caller // can write a filter that matches something. // @@ -129,8 +153,11 @@ func (c *Client) FetchDimensionValues(ctx context.Context, dimension string, met Limit: limit, } if search != "" { + // escapeLikePattern first, so the wildcards it introduces are part of + // the value that escapeSQLLiteral then quotes for the literal. args.Where = []string{fmt.Sprintf( - "{{ Dimension('%s') }} ILIKE '%%%s%%'", dimension, escapeSQLLiteral(search), + "{{ Dimension('%s') }} ILIKE '%%%s%%' ESCAPE '%s'", + dimension, escapeSQLLiteral(escapeLikePattern(search)), likeEscapeChar, )} } diff --git a/internal/dbtsl/parity_live_test.go b/internal/dbtsl/parity_live_test.go index bd69a45..242e605 100644 --- a/internal/dbtsl/parity_live_test.go +++ b/internal/dbtsl/parity_live_test.go @@ -201,6 +201,42 @@ func TestLiveDimensionValuesCountrySearch(t *testing.T) { } } +// TestLiveDimensionValuesTreatsSearchAsALiteral is the test that would have +// caught the wildcard leak. A stub cannot: it echoes whatever rows the test +// queued, so only the real ILIKE evaluation shows that "_" was matching any +// character. Before the ESCAPE clause, this search returned every country in +// the environment. +func TestLiveDimensionValuesTreatsSearchAsALiteral(t *testing.T) { + client := liveClient(t) + ctx, cancel := liveContext(t) + defer cancel() + + for _, search := range []string{"_", "%"} { + values, err := client.FetchDimensionValues(ctx, + "country__country_name", []string{"total_contributors"}, search, 100) + if err != nil { + t.Fatalf("FetchDimensionValues(%q) failed: %v", search, err) + } + // No country name contains a literal underscore or percent sign, so a + // correct literal search finds nothing. Wildcards find everything. + if values.ValueCount != 0 { + t.Errorf("search %q was treated as a wildcard: %d values returned (%v)", + search, values.ValueCount, values.Values[:min(5, len(values.Values))]) + } + } + + // The ordinary case must keep working: escaping is not allowed to break a + // search that has no metacharacters in it. + values, err := client.FetchDimensionValues(ctx, + "country__country_name", []string{"total_contributors"}, "viet", 100) + if err != nil { + t.Fatalf("control search failed: %v", err) + } + if !containsValue(values.Values, "Viet Nam") { + t.Errorf("escaping broke an ordinary search: %v", values.Values) + } +} + // TestLiveDimensionValuesGate confirms the metric gate holds against the real // API, not just the stub. Without it, any dimension in the semantic layer // would be enumerable, because a dimension-only query never consults the