prepare(): values and ID lists as placeholders, table and // column names as %i. Fragments added to a query are code (Scope::sql()) or are prepared // where they are built (Scope::type_sql(), Lang::sql(), SeoMeta joins). /** * ClusterEngine * * Builds clusters from WordPress taxonomies and computes cluster-level metrics * (Health, Archetype, Entropy, Gini, Density) based on data in clusterlane_post_metrics * and clusterlane_links. */ class ClusterEngine { /* ------------------------------------------------------------------ Content-classification tuning (cluster-relative role assignment) These gate classify_relative(). They exist so a post can't be promoted to "pillar" — or dismissed as "light" — on percentile alone, which is what let large ties of weakly-linked siblings all score as pillars (e.g. inbound [1,1,1,1,5] → the four 1-link posts each land at the 80th percentile because 4 of 5 members are <= 1). ------------------------------------------------------------------ */ /** Below this member count, percentile splits are noise; use a simpler rule. */ const MIN_CLUSTER_FOR_PERCENTILE = 4; /** A post needs at least this many inbound links to qualify as a pillar. */ const PILLAR_MIN_INBOUND = 2; /** A post with more than this many inbound links is never "light". */ const LIGHT_MAX_INBOUND = 1; /** * Pillars per cluster (0.98.108). The top quarter by inbound links used * to become pillars — 7 of 20, 13 of 43 — which flattened the word to * nothing. Now at most 1 pillar, or 2 in clusters of this size or more; * the rest of the top quarter are supports. A chosen pillar (Cluster * Inspector → "Make pillar") is the cluster's only pillar. */ const SECOND_PILLAR_FROM = 20; /** Option: cluster_key → post ID of the pillar the editor chose. */ const CHOSEN_PILLARS_OPTION = 'clusterlane_chosen_pillars'; /** The archetype of every product-category cluster (0.98.172). */ const PRODUCT_ARCHETYPE = 'Product Range'; /** @return array */ public static function chosen_pillars(): array { $v = get_option( self::CHOSEN_PILLARS_OPTION, [] ); return is_array( $v ) ? array_map( 'intval', $v ) : []; } public static function chosen_pillar( string $cluster_key ): int { return (int) ( self::chosen_pillars()[ $cluster_key ] ?? 0 ); } /** Set (post ID) or clear (0) the chosen pillar of a cluster. */ public static function set_chosen_pillar( string $cluster_key, int $post_id ): void { $all = self::chosen_pillars(); if ( $post_id > 0 ) $all[ $cluster_key ] = $post_id; else unset( $all[ $cluster_key ] ); update_option( self::CHOSEN_PILLARS_OPTION, $all, false ); } /* ------------------------------------------------------------------ Cluster sync ------------------------------------------------------------------ */ /** * Sync clusters from all configured taxonomies. * Respects min_term_count threshold — unless the user has explicitly * picked individual categories/tags on the Cluster Admin page, in * which case that selection is authoritative for those two * taxonomies (any other enabled taxonomy still falls back to the * min_term_count behavior). * Returns number of new clusters created. */ public function sync_all(): int { $settings = \Clusterlane\Core\Settings::get_instance(); $taxonomies = $settings ? $settings->taxonomies() : ['category']; $min_count = $settings ? $settings->min_term_count() : 1; $configured = $settings ? $settings->cluster_terms_configured() : false; // Cluster Admin is authoritative: tags picked there are built even if // "Tags" isn't ticked under Settings → Scan Settings → Taxonomies // (that hidden dependency made tag selections silently do nothing). if ( $configured && $settings->enabled_tags() && ! in_array( 'post_tag', $taxonomies, true ) ) { $taxonomies[] = 'post_tag'; } // Product categories picked in Cluster Admin (WooCommerce, 0.98.172). Without // WooCommerce their clusters are switched off, not deleted. if ( Shop::active() ) { if ( $configured && ! in_array( Shop::TAXONOMY, $taxonomies, true ) ) $taxonomies[] = Shop::TAXONOMY; } else { $taxonomies = array_values( array_diff( $taxonomies, [ Shop::TAXONOMY ] ) ); global $wpdb; $wpdb->query( $wpdb->prepare( "UPDATE {$wpdb->prefix}clusterlane_clusters SET is_active = 0 WHERE cluster_type = %s", Shop::TAXONOMY ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery } // Other taxonomies picked in Cluster Admin or onboarding (0.98.177): the // selection is authoritative, like categories and tags. $extra = $settings ? $settings->enabled_terms() : []; $taxonomies = array_values( array_diff( $taxonomies, array_keys( $extra ) ) ); $created = 0; foreach ( $extra as $taxonomy => $ids ) { if ( ! taxonomy_exists( $taxonomy ) ) continue; $created += $this->sync_from_taxonomy( $taxonomy, $min_count, $ids ); } // Clusters of a taxonomy that is no longer picked — or no longer exists (its // plugin was switched off) — are switched off, not deleted. global $wpdb; $known = array_merge( ClusterTypes::BUILT_IN, array_keys( array_filter( $extra, fn( $ids, $t ) => taxonomy_exists( $t ), ARRAY_FILTER_USE_BOTH ) ), $taxonomies ); $wpdb->query( $wpdb->prepare( "UPDATE {$wpdb->prefix}clusterlane_clusters SET is_active = 0 WHERE cluster_type NOT IN (" . implode( ',', array_fill( 0, count( $known ), '%s' ) ) . ')', $known ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery foreach ( $taxonomies as $taxonomy ) { $allowed = null; if ( $configured && $taxonomy === 'category' ) { $allowed = $settings->enabled_categories(); } elseif ( $configured && $taxonomy === 'post_tag' ) { $allowed = $settings->enabled_tags(); } elseif ( $taxonomy === Shop::TAXONOMY ) { $allowed = $settings->enabled_product_cats(); // only what was picked — never every product category } $created += $this->sync_from_taxonomy( $taxonomy, $min_count, $allowed ); } // Pages as clusters (0.98.180): after the switch-off above, which doesn't know them. $created += HubClusters::sync(); return $created; } /** * Sync clusters from a single taxonomy. * Only creates clusters for terms with at least $min_count posts, * unless $allowed_term_ids is given — an explicit selection from the * Cluster Admin page, which is then authoritative (bypasses * min_count; the settings UI already only lists terms with >= 2 * posts) and also DEACTIVATES any existing cluster of this taxonomy * whose term isn't in the selection, so unchecking a category/tag * there actually removes it from the dashboard. */ public function sync_from_taxonomy( string $taxonomy = 'category', int $min_count = 1, ?array $allowed_term_ids = null ): int { global $wpdb; $args = [ 'taxonomy' => $taxonomy, 'hide_empty' => true, 'number' => 0, 'orderby' => 'count', 'order' => 'DESC', ]; if ( $allowed_term_ids !== null ) { $args['include'] = ! empty( $allowed_term_ids ) ? $allowed_term_ids : [ 0 ]; // [0] never matches — deliberate "select none" } else { $args['min_count'] = $min_count; } $terms = get_terms( $args ); if ( is_wp_error( $terms ) ) return 0; $seen_term_ids = []; $created = 0; foreach ( $terms as $term ) { if ( $allowed_term_ids === null && (int) $term->count < $min_count ) continue; $seen_term_ids[] = (int) $term->term_id; $key = $taxonomy . '_' . $term->term_id; $existing = $wpdb->get_var( $wpdb->prepare( "SELECT id FROM {$wpdb->prefix}clusterlane_clusters WHERE cluster_key = %s", $key ) ); // WordPress stores term names with HTML entities pre-encoded // (e.g. "Food & Drink") — decode once here so cluster_label // holds raw text, matching what every consumer (esc_html() in // the dashboard table, escHtml() in JS) already assumes it can // safely escape itself. Skipping this meant an ampersand in a // category name rendered as a literal "&" on screen. $label = html_entity_decode( $term->name, ENT_QUOTES, 'UTF-8' ); if ( $existing ) { $wpdb->update( $wpdb->prefix . 'clusterlane_clusters', [ 'cluster_label' => $label, 'term_id' => $term->term_id, 'is_active' => 1, ], [ 'cluster_key' => $key ] ); } else { $wpdb->insert( $wpdb->prefix . 'clusterlane_clusters', [ 'cluster_key' => $key, 'cluster_label' => $label, 'cluster_type' => $taxonomy, 'term_id' => $term->term_id, 'is_active' => 1, ] ); $created++; } } if ( $allowed_term_ids !== null ) { $keep = $seen_term_ids ? array_map( 'intval', $seen_term_ids ) : [ 0 ]; $wpdb->query( $wpdb->prepare( "UPDATE {$wpdb->prefix}clusterlane_clusters SET is_active = 0 WHERE cluster_type = %s AND term_id NOT IN (" . implode( ',', array_fill( 0, count( $keep ), '%d' ) ) . ')', array_merge( [ $taxonomy ], $keep ) ) ); } return $created; } /* ------------------------------------------------------------------ Compute metrics for one cluster ------------------------------------------------------------------ */ /** * Compute and persist Health, Archetype and structural metrics * for a single cluster identified by its cluster_key. */ public function compute_cluster( string $cluster_key ): void { global $wpdb; $cluster = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$wpdb->prefix}clusterlane_clusters WHERE cluster_key = %s", $cluster_key ) ); if ( ! $cluster ) return; // Get all post IDs in this cluster. $post_ids = $this->get_member_ids( (int) $cluster->term_id, $cluster->cluster_type, $cluster_key ); $total = count( $post_ids ); if ( $total === 0 ) return; // Fetch metrics for cluster members. $post_ids = array_map( 'intval', $post_ids ); $metrics = $wpdb->get_results( $wpdb->prepare( "SELECT post_id, overall_score, inbound_links, outbound_links FROM {$wpdb->prefix}clusterlane_post_metrics WHERE post_id IN (" . implode( ',', array_fill( 0, count( $post_ids ), '%d' ) ) . ")", $post_ids ) ); if ( empty( $metrics ) ) return; // --- Basic stats --- $avg_overall = round( array_sum( array_column( (array) $metrics, 'overall_score' ) ) / count( $metrics ) ); $inbound_vals = array_column( (array) $metrics, 'inbound_links' ); $orphan_count = count( array_filter( $inbound_vals, fn($v) => (int)$v === 0 ) ); $orphan_ratio = $total > 0 ? $orphan_count / $total : 0; // --- Inbound link count for each member --- $inbound_sum = array_sum( $inbound_vals ); // --- Density: avg outbound within cluster --- // Count links between cluster members only. $internal_links = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->prefix}clusterlane_links WHERE source_id IN (" . implode( ',', array_fill( 0, count( $post_ids ), '%d' ) ) . ") AND target_id IN (" . implode( ',', array_fill( 0, count( $post_ids ), '%d' ) ) . ") AND target_type = 'post'", array_merge( $post_ids, $post_ids ) ) ); $max_links = $total > 1 ? $total * ( $total - 1 ) : 1; $density = round( ( $internal_links / $max_links ) * 100 ); // --- Gini coefficient (inequality of inbound distribution) --- $gini = $this->gini( $inbound_vals ); // --- Entropy (topic breadth — based on category overlap) --- $entropy = $this->entropy_score( $post_ids ); // --- Health score --- $health = $this->health( (int) $avg_overall, $orphan_ratio, (int) $density, $gini, $entropy, $total ); // --- Archetype --- $archetype = $this->archetype( $entropy, $gini, (int) $density, $orphan_ratio, $total, (int) $avg_overall ); // WooCommerce product categories (0.98.172): products are not meant to link to each // other, so density, evenness and breadth say nothing about a shop. What matters: // good product pages (their own score) and articles linking to them (share of // products with at least one link pointing to them). Half each. if ( $cluster->cluster_type === Shop::TAXONOMY ) { $health = (int) max( 0, min( 100, round( $avg_overall * 0.5 + ( 1 - $orphan_ratio ) * 50 ) ) ); $archetype = self::PRODUCT_ARCHETYPE; } // Persist. $wpdb->update( $wpdb->prefix . 'clusterlane_clusters', [ 'total_members' => $total, 'health_score' => $health, 'entropy_score' => $entropy, 'gini_score' => $gini, 'density_score' => $density, 'archetype' => $archetype, 'last_computed' => current_time( 'mysql' ), ], [ 'cluster_key' => $cluster_key ] ); // Update primary_cluster on each member's metrics row. $wpdb->query( $wpdb->prepare( "UPDATE {$wpdb->prefix}clusterlane_post_metrics SET primary_cluster = %s WHERE post_id IN (" . implode( ',', array_fill( 0, count( $post_ids ), '%d' ) ) . ") AND (primary_cluster IS NULL OR primary_cluster = '')", array_merge( [ $cluster_key ], $post_ids ) ) ); // --- Cluster-relative content classification --- // Recompute each member's role (pillar / support / light / orphan) // against THIS cluster's own inbound-link distribution, then // replace the stored rows for this cluster wholesale so members // that have left the cluster don't leave stale rows behind. $this->classify_cluster_members( $cluster_key, (array) $metrics, $total ); self::sync_site_roles( array_map( 'intval', $post_ids ) ); } /** * The site-wide role of an article (Article Inspector without a cluster * filter, Text Match, snapshots) = its best role in any of its clusters: * pillar > support > light > orphan. So "Pillars" site-wide are the * clusters' pillars, not every well-linked article (0.98.145). Articles * in no cluster keep the scanner's guess, which is never "pillar". * @param int[]|null $ids null = every article */ public static function sync_site_roles( ?array $ids = null ): void { global $wpdb; $pm = $wpdb->prefix . 'clusterlane_post_metrics'; $cpc = $wpdb->prefix . 'clusterlane_cluster_post_class'; if ( $ids === null ) { $wpdb->query( $wpdb->prepare( "UPDATE %i m JOIN ( SELECT post_id, MIN( FIELD( content_class, 'pillar', 'support', 'light', 'orphan' ) ) AS r FROM %i GROUP BY post_id ) x ON x.post_id = m.post_id AND x.r > 0 SET m.content_class = ELT( x.r, 'pillar', 'support', 'light', 'orphan' )", $pm, $cpc ) ); // Not in any cluster (any more): never a pillar. $wpdb->query( $wpdb->prepare( "UPDATE %i m LEFT JOIN %i c ON c.post_id = m.post_id SET m.content_class = 'support' WHERE m.content_class = 'pillar' AND c.post_id IS NULL", $pm, $cpc ) ); return; } $ids = array_values( array_map( 'intval', $ids ) ); if ( ! $ids ) return; $wpdb->query( $wpdb->prepare( "UPDATE %i m JOIN ( SELECT post_id, MIN( FIELD( content_class, 'pillar', 'support', 'light', 'orphan' ) ) AS r FROM %i WHERE post_id IN (" . implode( ',', array_fill( 0, count( $ids ), '%d' ) ) . ") GROUP BY post_id ) x ON x.post_id = m.post_id AND x.r > 0 SET m.content_class = ELT( x.r, 'pillar', 'support', 'light', 'orphan' )", array_merge( [ $pm, $cpc ], $ids ) ) ); $wpdb->query( $wpdb->prepare( "UPDATE %i m LEFT JOIN %i c ON c.post_id = m.post_id SET m.content_class = 'support' WHERE m.content_class = 'pillar' AND c.post_id IS NULL AND m.post_id IN (" . implode( ',', array_fill( 0, count( $ids ), '%d' ) ) . ')', array_merge( [ $pm, $cpc ], $ids ) ) ); } /** * Classify every member of a cluster relative to that cluster's own * inbound-link distribution, and persist the result. * * @param object[] $metrics Rows with ->post_id and ->inbound_links for * this cluster's members (from compute_cluster()). */ private function classify_cluster_members( string $cluster_key, array $metrics, int $total ): void { global $wpdb; $inbound_vals = array_map( fn( $m ) => (int) $m->inbound_links, $metrics ); $max_inbound = $total > 0 ? max( $inbound_vals ) : 0; $roles = []; foreach ( $metrics as $m ) { $roles[ (int) $m->post_id ] = $this->classify_relative( $inbound_vals, (int) $m->inbound_links, $max_inbound ); } // Few pillars: keep the best-linked candidates (ties → higher overall // score), demote the rest to support. A chosen pillar is the only one. $chosen = self::chosen_pillar( $cluster_key ); if ( $chosen && ! isset( $roles[ $chosen ] ) ) $chosen = 0; // no longer a member $by_id = []; foreach ( $metrics as $m ) $by_id[ (int) $m->post_id ] = $m; $cands = array_keys( array_filter( $roles, fn( $r ) => $r[0] === 'pillar' ) ); usort( $cands, fn( $a, $b ) => ( (int) $by_id[ $b ]->inbound_links <=> (int) $by_id[ $a ]->inbound_links ) ?: ( (int) $by_id[ $b ]->overall_score <=> (int) $by_id[ $a ]->overall_score ) ); // A page-cluster's own page is its pillar unless another was chosen (0.98.180). $hub = strpos( $cluster_key, HubClusters::PREFIX ) === 0 ? (int) substr( $cluster_key, strlen( HubClusters::PREFIX ) ) : 0; if ( ! $chosen && $hub && isset( $roles[ $hub ] ) ) $chosen = $hub; $keep = $chosen ? [ $chosen ] : array_slice( $cands, 0, $total >= self::SECOND_PILLAR_FROM ? 2 : 1 ); foreach ( $roles as $pid => &$r ) { if ( in_array( $pid, $keep, true ) ) $r[0] = 'pillar'; elseif ( $r[0] === 'pillar' ) $r[0] = 'support'; } unset( $r ); $values = []; // six per row, in column order $now = current_time( 'mysql' ); foreach ( $metrics as $m ) { $post_id = (int) $m->post_id; [ $class, $percentile ] = $roles[ $post_id ]; array_push( $values, $cluster_key, $post_id, $class, (int) $m->inbound_links, (int) $percentile, $now ); } $rows = intdiv( count( $values ), 6 ); $table = $wpdb->prefix . 'clusterlane_cluster_post_class'; // Wholesale replace for this cluster_key: drop members that no // longer belong, then insert the freshly computed set. $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE cluster_key = %s', $table, $cluster_key ) ); if ( $rows > 0 ) { // phpcs:disable PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- SQL assembled from code-only fragments (no values); every value is a placeholder passed to $wpdb->prepare(). $wpdb->query( $wpdb->prepare( 'INSERT INTO %i (cluster_key, post_id, content_class, inbound_links, percentile, computed_at) VALUES ' . implode( ', ', array_fill( 0, $rows, '(%s, %d, %s, %d, %d, %s)' ) ), array_merge( [ $table ], $values ) ) ); // phpcs:enable PluginCheck.Security.DirectDB.UnescapedDBParameter, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber } } /** * Classify a single post's structural role relative to its OWN * cluster's inbound-link distribution, rather than against a fixed * global threshold. A post with 5 inbound links is a pillar in a * sparse 8-post cluster and unremarkable in a dense 40-post mesh — * so the threshold has to move with the cluster. * * The reported percentile is a ceiling CDF: the share of cluster * members whose inbound count is <= this post's. That number is only * ever used for display; the *class* is decided with two guards on * top of it, because a bare CDF misclassifies in two ways: * * 1. Flat clusters. If every member has the same inbound count, the * CDF is 100 for all of them, so a naive ">=75 -> pillar" rule * makes the entire cluster pillars. Members here are structurally * equivalent, so they are all 'support'. * 2. Bottom ties. With a <= CDF, a large tie at a low value inflates * everyone's percentile: inbound [1,1,1,1,5] puts each 1-link post * at the 80th percentile. An absolute inbound floor (PILLAR_MIN_INBOUND) * stops those from becoming pillars. * * Bands, once the guards pass: * - 0 inbound links -> orphan (any cluster) * - all members tied -> support (any cluster) * - >= 75th percentile AND >= PILLAR_MIN_INBOUND -> pillar * - < 35th percentile AND <= LIGHT_MAX_INBOUND -> light * - everything else -> support * * Small clusters (< MIN_CLUSTER_FOR_PERCENTILE) don't have enough * points for a meaningful percentile split, so only the single * best-linked post — if it clears the pillar floor — is a pillar, and * everyone else is 'support'. 'light' is never emitted below that size: * it means "peripheral in a rich structure", which is meaningless at * n=2 or n=3. * * @return array{0: string, 1: int} [content_class, percentile 0-100] */ private function classify_relative( array $inbound_vals, int $post_inbound, int $max_inbound ): array { // 0 inbound links is an orphan in any cluster, of any size. if ( $post_inbound === 0 ) return [ 'orphan', 0 ]; $total = count( $inbound_vals ); $min_inbound = $total > 0 ? min( $inbound_vals ) : 0; // Reported percentile (ceiling CDF), used for display in every branch below. $rank = count( array_filter( $inbound_vals, fn( $v ) => $v <= $post_inbound ) ); $percentile = $total > 0 ? (int) round( ( $rank / $total ) * 100 ) : 0; // Flat cluster: everyone tied. No basis to single anyone out. if ( $max_inbound === $min_inbound ) { return [ 'support', $percentile ]; } // Small cluster: percentile splits are noise. Only the top post is // a pillar (and only if it clears the floor); no 'light' at this size. if ( $total < self::MIN_CLUSTER_FOR_PERCENTILE ) { $class = ( $post_inbound === $max_inbound && $post_inbound >= self::PILLAR_MIN_INBOUND ) ? 'pillar' : 'support'; return [ $class, $percentile ]; } // Normal cluster: percentile bands, gated on absolute inbound floors. $class = match ( true ) { $percentile >= 75 && $post_inbound >= self::PILLAR_MIN_INBOUND => 'pillar', $percentile < 35 && $post_inbound <= self::LIGHT_MAX_INBOUND => 'light', default => 'support', }; return [ $class, $percentile ]; } /** * Compute metrics for all active clusters. * Returns number of clusters processed. */ public function compute_all(): int { global $wpdb; // Links from articles scanned after an article weren't in its counts yet (0.98.179). if ( class_exists( Scanner::class ) ) Scanner::refresh_counts(); $keys = $wpdb->get_col( "SELECT cluster_key FROM {$wpdb->prefix}clusterlane_clusters WHERE is_active = 1" ); foreach ( $keys as $key ) { $this->compute_cluster( $key ); } /** Fires after Metrics — History takes the day's snapshot here. */ do_action( 'clusterlane_metrics_computed', count( $keys ) ); return count( $keys ); } /** * The site's cluster health as the Dashboard ring shows it: the average * weighted by articles, so a 43-article cluster counts for more than a * 5-article one. One definition for the Dashboard, snapshots and reports. * @param object[] $clusters rows from get_clusters() */ public static function weighted_health( array $clusters ): int { $sum = 0; $n = 0; foreach ( $clusters as $c ) { $sum += (int) $c->health_score * (int) $c->total_members; $n += (int) $c->total_members; } return $n > 0 ? (int) round( $sum / $n ) : 0; } /* ------------------------------------------------------------------ Read ------------------------------------------------------------------ */ /** Get all active clusters ordered by health ascending. */ public function get_clusters(): array { global $wpdb; return $wpdb->get_results( "SELECT * FROM {$wpdb->prefix}clusterlane_clusters WHERE is_active = 1 ORDER BY health_score ASC" ) ?: []; } /** Get cluster summary stats. Empty clusters (nothing scanned in them yet) don't count towards the average health. */ public function get_summary(): array { global $wpdb; $table = $wpdb->prefix . 'clusterlane_clusters'; $row = $wpdb->get_row( $wpdb->prepare( 'SELECT COUNT(*) AS total, ROUND(AVG(CASE WHEN total_members > 0 THEN health_score END),1) AS avg_health, SUM(total_members) AS total_articles FROM %i WHERE is_active = 1', $table ), ARRAY_A ); return [ 'total_clusters' => (int) ( $row['total'] ?? 0 ), 'avg_health' => (float) ( $row['avg_health'] ?? 0 ), 'total_articles' => (int) ( $row['total_articles'] ?? 0 ), ]; } /* ------------------------------------------------------------------ Private helpers ------------------------------------------------------------------ */ /** Get post IDs belonging to a cluster — handles both taxonomy and custom clusters. */ /** * Resolves a cluster's member post IDs. Public (and with the * object-based wrapper below) so REST endpoints can share this * instead of re-implementing the custom-vs-taxonomy branch — RestGraph, * RestGap, and RestLinkSuggest all need exactly this resolution. */ public function get_member_ids( int $term_id, string $cluster_type, string $cluster_key = '' ): array { // A page and the pages pointing to it (0.98.180). if ( HubClusters::is_hub_type( $cluster_type ) ) return HubClusters::members( $term_id ); // Custom cluster — fetch from clusterlane_custom_members. if ( $cluster_type === 'custom' ) { global $wpdb; // Only members that are still in scope (published, not protected). // phpcs:disable PluginCheck.Security.DirectDB.UnescapedDBParameter -- SQL assembled from code-only fragments (no values); every value is a placeholder passed to $wpdb->prepare(). $ids = $wpdb->get_col( $wpdb->prepare( "SELECT cm.post_id FROM {$wpdb->prefix}clusterlane_custom_members cm INNER JOIN {$wpdb->posts} p ON p.ID = cm.post_id WHERE cm.cluster_key = %s AND " . Scope::sql( 'p' ), // phpcs:ignore $cluster_key ) ); // phpcs:enable PluginCheck.Security.DirectDB.UnescapedDBParameter return array_map( 'intval', $ids ); } // Taxonomy cluster. $posts = get_posts( Scope::query_args( [ 'post_type' => Scope::post_types(), 'post_status' => 'publish', 'posts_per_page' => -1, 'fields' => 'ids', 'tax_query' => [[ // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query -- members of one term, ids only 'taxonomy' => $cluster_type, 'field' => 'term_id', 'terms' => $term_id, 'include_children' => false, ]], 'no_found_rows' => true, 'update_post_meta_cache' => false, 'update_post_term_cache' => false, ] ) ); return array_map( 'intval', $posts ); } /** Same as get_member_ids(), but takes a clusterlane_clusters row directly. */ public function get_member_post_ids( object $cluster ): array { return $this->get_member_ids( (int) ( $cluster->term_id ?? 0 ), $cluster->cluster_type, $cluster->cluster_key ?? '' ); } /** Gini coefficient (0-100) for an array of values. */ private function gini( array $values ): int { $n = count( $values ); if ( $n < 2 ) return 0; sort( $values ); $sum = array_sum( $values ); if ( $sum === 0 ) return 0; $cumsum = 0; $index = 0; foreach ( $values as $v ) { $index++; $cumsum += $v; } // Standard Gini formula. $weighted = 0; $i = 1; foreach ( $values as $v ) { $weighted += ( 2 * $i - $n - 1 ) * $v; $i++; } return (int) round( abs( $weighted / ( $n * $sum ) ) * 100 ); } /** * Entropy score (0-100): measures how evenly posts link to each other. * High entropy = many different link targets (broad cluster). * Low entropy = links concentrated on a few hubs. */ private function entropy_score( array $post_ids ): int { if ( count( $post_ids ) < 2 ) return 0; global $wpdb; $post_ids = array_map( 'intval', $post_ids ); $outbound_counts = $wpdb->get_col( $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->prefix}clusterlane_links WHERE source_id IN (" . implode( ',', array_fill( 0, count( $post_ids ), '%d' ) ) . ") AND target_id IN (" . implode( ',', array_fill( 0, count( $post_ids ), '%d' ) ) . ") AND target_type = 'post' GROUP BY source_id", array_merge( $post_ids, $post_ids ) ) ); if ( empty( $outbound_counts ) ) return 0; $total = array_sum( $outbound_counts ); if ( $total === 0 ) return 0; $entropy = 0.0; foreach ( $outbound_counts as $c ) { $p = $c / $total; $entropy -= $p * log( $p, 2 ); } // Normalise to 0-100 (max entropy = log2(n)). $max_entropy = log( count( $outbound_counts ), 2 ); if ( $max_entropy <= 0 ) return 0; return (int) round( ( $entropy / $max_entropy ) * 100 ); } /** Health score (0-100) weighted formula. */ private function health( int $avg_overall, float $orphan_ratio, int $density, int $gini, int $entropy, int $total ): int { $orphan_score = (int) round( ( 1 - $orphan_ratio ) * 100 ); $gini_score = 100 - $gini; // lower Gini = more equal = better $health = (int) round( $avg_overall * 0.30 + $orphan_score * 0.25 + $density * 0.20 + $gini_score * 0.15 + $entropy * 0.10 ); return min( 100, max( 0, $health ) ); } /** Classify cluster archetype based on structural metrics and user-configured thresholds. */ private function archetype( int $entropy, int $gini, int $density, float $orphan_ratio, int $total, int $avg_overall ): string { // Load thresholds from settings. $settings = \Clusterlane\Core\Settings::get_instance(); $t = wp_parse_args( $settings ? $settings->get( 'thresholds', [] ) : [], [ 'balanced_mesh_entropy' => 80, 'balanced_mesh_gini' => 40, 'authority_pyramid_entropy' => 60, 'authority_pyramid_gini' => 55, 'developed_min_posts' => 12, 'developed_min_score' => 90, 'developed_max_orphan' => 10, 'emerging_max_posts' => 6, ] ); if ( $total < (int) $t['emerging_max_posts'] ) return 'Emerging Cluster'; if ( $entropy > (int) $t['balanced_mesh_entropy'] && $gini < (int) $t['balanced_mesh_gini'] ) return 'Balanced Mesh'; if ( $entropy > (int) $t['authority_pyramid_entropy'] && $gini > (int) $t['authority_pyramid_gini'] ) return 'Authority Pyramid'; if ( $entropy < 50 && $gini > 70 ) return 'Over-Centralized Hub'; if ( $density < 8 ) return 'Weak Structure'; if ( $orphan_ratio > 0.25 ) return 'Fragmented'; if ( $total >= (int) $t['developed_min_posts'] && $avg_overall >= (int) $t['developed_min_score'] && $orphan_ratio <= ( (int) $t['developed_max_orphan'] / 100 ) && $density >= 8 ) { return 'Developed Cluster'; } // Analysed, but no single structural pattern dominates (0.98.109: // was "Unclassified", which read as "not analysed yet"). An empty // archetype still means "never computed". return 'Mixed'; } }