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. 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/allowlist.go b/internal/dbtsl/allowlist.go new file mode 100644 index 0000000..370b1b5 --- /dev/null +++ b/internal/dbtsl/allowlist.go @@ -0,0 +1,283 @@ +// Copyright The Linux Foundation and contributors. +// SPDX-License-Identifier: MIT + +// Package dbtsl provides a client for the dbt Semantic Layer API. +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 closest []ranked + for _, metric := range AllowedMetricNames() { + if r := similarityRatio(name, metric); r >= cutoff { + closest = append(closest, ranked{ratio: r, name: metric}) + } + } + sort.SliceStable(closest, func(i, j int) bool { return closest[i].ratio > closest[j].ratio }) + names := make([]string, 0, limit) + for _, c := range closest { + 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..ce6629b --- /dev/null +++ b/internal/dbtsl/cache.go @@ -0,0 +1,92 @@ +// Copyright The Linux Foundation and contributors. +// SPDX-License-Identifier: MIT + +// Package dbtsl provides a client for the dbt Semantic Layer API. +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..0e48247 --- /dev/null +++ b/internal/dbtsl/client.go @@ -0,0 +1,221 @@ +// 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. +// +// 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 +// 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) { + // 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) + 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") + } + 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: scheme + 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..c29dbfb --- /dev/null +++ b/internal/dbtsl/dbtsl_test.go @@ -0,0 +1,1144 @@ +// Copyright The Linux Foundation and contributors. +// SPDX-License-Identifier: MIT + +package dbtsl + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "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() +// --------------------------------------------------------------------------- + +// 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) { + raw, err := os.ReadFile(filepath.Join("testdata", "difflib_ratios.json")) + if err != nil { + t.Fatalf("reading fixture: %v", err) + } + 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 { + 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) + } +} + +// 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 +// --------------------------------------------------------------------------- + +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") + } +} + +// 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"}]}`) + + 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") + } +} + +// 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) + + _, 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") + } +} + +// 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) + 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. +// 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":` + 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), exact) { + t.Errorf("expected the exact integer, got %s", encoded) + } + if strings.Contains(string(encoded), "e+") { + 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/dbtsl/dimensionvalues.go b/internal/dbtsl/dimensionvalues.go new file mode 100644 index 0000000..0d10631 --- /dev/null +++ b/internal/dbtsl/dimensionvalues.go @@ -0,0 +1,226 @@ +// Copyright The Linux Foundation and contributors. +// SPDX-License-Identifier: MIT + +// Package dbtsl provides a client for the dbt Semantic Layer API. +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, `\`, `\\`), `'`, `''`) +} + +// 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. +// +// 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, + )} + } + + // 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, ", "), + )} + } + + 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 != "" { + // 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%%' ESCAPE '%s'", + dimension, escapeSQLLiteral(escapeLikePattern(search)), likeEscapeChar, + )} + } + + 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..72a57cb --- /dev/null +++ b/internal/dbtsl/metadata.go @@ -0,0 +1,308 @@ +// Copyright The Linux Foundation and contributors. +// SPDX-License-Identifier: MIT + +// Package dbtsl provides a client for the dbt Semantic Layer API. +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, $pageNum: Int!) { + metricsPaginated(environmentId: $environmentId, search: $search, pageNum: $pageNum) { + totalPages + items { + name + label + description + type + } + } +} +` + +const gqlMetricsWithRelated = ` +query GetMetricsWithRelated($environmentId: BigInt!, $search: String, $pageNum: Int!) { + metricsPaginated(environmentId: $environmentId, search: $search, pageNum: $pageNum) { + totalPages + items { + name + label + description + type + dimensions { + name + } + entities { + name + } + } + } +} +` + +const gqlDimensions = ` +query GetDimensions($environmentId: BigInt!, $metrics: [MetricInput!]!, $pageNum: Int!) { + dimensionsPaginated(environmentId: $environmentId, metrics: $metrics, pageNum: $pageNum) { + totalPages + 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"` + TotalPages int `json:"totalPages"` + } `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"` + TotalPages int `json:"totalPages"` + } `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) { + query := gqlMetrics + if includeDimensions { + query = gqlMetricsWithRelated + } + + var metrics []MetricInfo + + // 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 + } + + var resp metricsResponse + if err := c.graphqlRequest(ctx, query, variables, &resp); err != nil { + return nil, err + } + 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) + } + } + 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}) + } + + // 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} + + 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, + }) + } + } + + 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..242e605 --- /dev/null +++ b/internal/dbtsl/parity_live_test.go @@ -0,0 +1,360 @@ +// 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) + } +} + +// 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 +// 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..b9a361f --- /dev/null +++ b/internal/dbtsl/query.go @@ -0,0 +1,432 @@ +// Copyright The Linux Foundation and contributors. +// SPDX-License-Identifier: MIT + +// Package dbtsl provides a client for the dbt Semantic Layer API. +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!, $pageNum: Int!) { + query(environmentId: $environmentId, queryId: $queryId, pageNum: $pageNum) { + status + error + sql + totalPages + 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. +// +// 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 + 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 ( + 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"` + TotalPages int `json:"totalPages"` + 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) { + ctx, cancel := context.WithTimeout(ctx, queryMaxWait) + defer cancel() + + queryID, err := c.createQuery(ctx, args) + if err != nil { + return nil, err + } + + 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 + + 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) + } + } + + 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, pageNum int) (*queryResultResponse, error) { + var resp queryResultResponse + variables := map[string]any{"queryId": queryID, "pageNum": pageNum} + if err := c.graphqlRequest(ctx, gqlQueryResult, variables, &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..82f3ee0 --- /dev/null +++ b/internal/dbtsl/search.go @@ -0,0 +1,59 @@ +// 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" + +// 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..60da017 --- /dev/null +++ b/internal/dbtsl/similarity.go @@ -0,0 +1,84 @@ +// 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 +// 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. +// 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) { + // 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 := range b { + if a[i] != b[j] { + cur[j] = 0 + continue + } + if j == 0 { + cur[j] = 1 + } else { + cur[j] = prev[j-1] + 1 + } + 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 new file mode 100644 index 0000000..8c2a5ca --- /dev/null +++ b/internal/tools/csv.go @@ -0,0 +1,44 @@ +// Copyright The Linux Foundation and contributors. +// SPDX-License-Identifier: MIT + +// Package tools provides MCP tool implementations for the LFX MCP server. +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..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 { @@ -79,132 +25,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 +41,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 +61,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 +147,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 +186,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..eba2806 --- /dev/null +++ b/internal/tools/semanticlayer.go @@ -0,0 +1,545 @@ +// Copyright The Linux Foundation and contributors. +// SPDX-License-Identifier: MIT + +// Package tools provides MCP tool implementations for the LFX MCP server. +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))) + } + + 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") + } + + 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)) + } + // 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)) + } + + 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)) +} + +// 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 new file mode 100644 index 0000000..9f56656 --- /dev/null +++ b/internal/tools/semanticlayer_test.go @@ -0,0 +1,862 @@ +// 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) + } +} + +// 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) + } + } +} + +// 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) { + 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) + } + }) + } +}