chore: initial snapshot of 2meet-data-optimizer v0.1.0
Baseline before backporting wp-data-optimizer v3.0.1-v3.4.6. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TbG1keQQ7XBa7qMQY16KCY
This commit is contained in:
@@ -0,0 +1,396 @@
|
||||
<?php
|
||||
/**
|
||||
* Zone Classifier for wp_postmeta analysis and zone assignment suggestions.
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Zone Classifier — analyzes wp_postmeta and suggests zone assignments.
|
||||
*
|
||||
* Examines meta_key usage patterns across the database to recommend
|
||||
* which zone each key should belong to:
|
||||
*
|
||||
* Hot (A) — Used in WP_Query meta_query (search/filter); numeric or short values
|
||||
* Warm (B) — Transient/computed data; infrequently updated
|
||||
* Cold (C) — Display data read often but never queried; long text/JSON blobs
|
||||
* Archive (D) — Belongs to trashed/old posts; rarely if ever accessed
|
||||
*
|
||||
* Signals analyzed:
|
||||
* - Value length distribution (short = hot candidate, long = cold candidate)
|
||||
* - Post status distribution (trash/draft heavy = archive candidate)
|
||||
* - Key prefix patterns (hp_, _hp_, _transient_ etc.)
|
||||
* - Whether the key appears in meta_query (via slow query log or heuristics)
|
||||
* - Distinct value cardinality (low = likely enum/flag = hot)
|
||||
*
|
||||
* Used by Admin UI and WP-CLI `wp wpdo analyze` command.
|
||||
*/
|
||||
class TMDO_Zone_Classifier {
|
||||
|
||||
/**
|
||||
* Analyze postmeta for a specific post type and return zone suggestions.
|
||||
*
|
||||
* @param string $post_type Post type to analyze.
|
||||
* @param int $sample_size Number of rows to sample per meta_key.
|
||||
* @return array Array of suggestions, each with: meta_key, suggested_zone, confidence, reasons.
|
||||
*/
|
||||
public static function analyze( string $post_type, int $sample_size = 100 ): array {
|
||||
global $wpdb;
|
||||
|
||||
// Return cached result if available (TTL: 1 hour).
|
||||
$transient_key = 'wpdo_classifier_' . sanitize_key( $post_type );
|
||||
$cached = get_transient( $transient_key );
|
||||
if ( false !== $cached ) {
|
||||
return $cached;
|
||||
}
|
||||
|
||||
// Get all distinct meta_keys for this post type.
|
||||
$keys = $wpdb->get_results(
|
||||
$wpdb->prepare(
|
||||
"SELECT pm.meta_key, COUNT(*) as row_count
|
||||
FROM {$wpdb->postmeta} pm
|
||||
INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
|
||||
WHERE p.post_type = %s
|
||||
GROUP BY pm.meta_key
|
||||
ORDER BY row_count DESC
|
||||
LIMIT 200",
|
||||
$post_type
|
||||
),
|
||||
ARRAY_A
|
||||
);
|
||||
|
||||
if ( empty( $keys ) ) {
|
||||
return array();
|
||||
}
|
||||
|
||||
$suggestions = array();
|
||||
$registry = TMDO_Schema_Registry::instance();
|
||||
|
||||
foreach ( $keys as $key_info ) {
|
||||
$meta_key = $key_info['meta_key'];
|
||||
$row_count = (int) $key_info['row_count'];
|
||||
|
||||
// Skip WordPress internal keys.
|
||||
if ( self::is_wp_internal( $meta_key ) ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if already registered.
|
||||
$existing = $registry->get_field( $post_type, $meta_key );
|
||||
$already_assigned = $existing ? $existing['zone'] : null;
|
||||
|
||||
// Collect signals.
|
||||
$signals = self::collect_signals( $meta_key, $post_type, $row_count, $sample_size );
|
||||
|
||||
// Score each zone.
|
||||
$scores = self::score_zones( $signals );
|
||||
|
||||
// Pick the best zone.
|
||||
arsort( $scores );
|
||||
$best_zone = array_key_first( $scores );
|
||||
$confidence = $scores[ $best_zone ];
|
||||
|
||||
$suggestions[] = array(
|
||||
'meta_key' => $meta_key,
|
||||
'row_count' => $row_count,
|
||||
'suggested_zone' => $best_zone,
|
||||
'confidence' => round( $confidence, 2 ),
|
||||
'already_assigned' => $already_assigned,
|
||||
'scores' => $scores,
|
||||
'reasons' => self::build_reasons( $signals, $best_zone ),
|
||||
);
|
||||
}
|
||||
|
||||
// Sort by confidence descending.
|
||||
usort( $suggestions, fn( $a, $b ) => $b['confidence'] <=> $a['confidence'] );
|
||||
|
||||
set_transient( $transient_key, $suggestions, HOUR_IN_SECONDS );
|
||||
|
||||
return $suggestions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Quick summary: count of meta_keys per suggested zone.
|
||||
*
|
||||
* @param string $post_type Post type to analyze.
|
||||
* @return array{hot: int, warm: int, cold: int, archive: int, already_assigned: int}
|
||||
*/
|
||||
public static function summary( string $post_type ): array {
|
||||
$suggestions = self::analyze( $post_type );
|
||||
$summary = array(
|
||||
'hot' => 0,
|
||||
'warm' => 0,
|
||||
'cold' => 0,
|
||||
'archive' => 0,
|
||||
'already_assigned' => 0,
|
||||
);
|
||||
|
||||
foreach ( $suggestions as $s ) {
|
||||
if ( $s['already_assigned'] ) {
|
||||
++$summary['already_assigned'];
|
||||
} else {
|
||||
++$summary[ $s['suggested_zone'] ];
|
||||
}
|
||||
}
|
||||
|
||||
return $summary;
|
||||
}
|
||||
|
||||
// ── Private helpers ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Collect analytical signals for a meta_key.
|
||||
*
|
||||
* @param string $meta_key Meta key to analyze.
|
||||
* @param string $post_type Post type context.
|
||||
* @param int $row_count Total rows for this meta key.
|
||||
* @param int $sample_size Number of rows sampled.
|
||||
* @return array Signal data for zone scoring.
|
||||
*/
|
||||
private static function collect_signals( string $meta_key, string $post_type, int $row_count, int $sample_size ): array {
|
||||
global $wpdb;
|
||||
|
||||
$signals = array(
|
||||
'meta_key' => $meta_key,
|
||||
'row_count' => $row_count,
|
||||
'avg_length' => 0,
|
||||
'max_length' => 0,
|
||||
'distinct_values' => 0,
|
||||
'numeric_ratio' => 0.0,
|
||||
'trash_ratio' => 0.0,
|
||||
'is_serialized' => false,
|
||||
'is_json' => false,
|
||||
'prefix' => '',
|
||||
);
|
||||
|
||||
// Prefix detection.
|
||||
if ( str_starts_with( $meta_key, 'hp_' ) || str_starts_with( $meta_key, '_hp_' ) ) {
|
||||
$signals['prefix'] = 'hivepress';
|
||||
} elseif ( str_starts_with( $meta_key, '_transient_' ) || str_starts_with( $meta_key, '_site_transient_' ) ) {
|
||||
$signals['prefix'] = 'transient';
|
||||
} elseif ( str_starts_with( $meta_key, '_' ) ) {
|
||||
$signals['prefix'] = 'internal';
|
||||
}
|
||||
|
||||
// Sample values for analysis.
|
||||
$samples = $wpdb->get_col(
|
||||
$wpdb->prepare(
|
||||
"SELECT pm.meta_value
|
||||
FROM {$wpdb->postmeta} pm
|
||||
INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
|
||||
WHERE p.post_type = %s AND pm.meta_key = %s
|
||||
LIMIT %d",
|
||||
$post_type,
|
||||
$meta_key,
|
||||
$sample_size
|
||||
)
|
||||
);
|
||||
|
||||
if ( ! empty( $samples ) ) {
|
||||
$lengths = array_map( 'strlen', $samples );
|
||||
$signals['avg_length'] = (int) ( array_sum( $lengths ) / count( $lengths ) );
|
||||
$signals['max_length'] = max( $lengths );
|
||||
|
||||
$numeric_count = 0;
|
||||
foreach ( $samples as $val ) {
|
||||
if ( is_numeric( $val ) ) {
|
||||
++$numeric_count;
|
||||
}
|
||||
}
|
||||
$signals['numeric_ratio'] = $numeric_count / count( $samples );
|
||||
|
||||
// Check serialized/JSON.
|
||||
$first = $samples[0] ?? '';
|
||||
$signals['is_serialized'] = is_serialized( $first );
|
||||
$signals['is_json'] = ( str_starts_with( $first, '{' ) || str_starts_with( $first, '[' ) )
|
||||
&& null !== json_decode( $first );
|
||||
}
|
||||
|
||||
// Distinct value count.
|
||||
$signals['distinct_values'] = (int) $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT COUNT(DISTINCT pm.meta_value)
|
||||
FROM {$wpdb->postmeta} pm
|
||||
INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
|
||||
WHERE p.post_type = %s AND pm.meta_key = %s",
|
||||
$post_type,
|
||||
$meta_key
|
||||
)
|
||||
);
|
||||
|
||||
// Trash ratio.
|
||||
if ( $row_count > 0 ) {
|
||||
$trash_count = (int) $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT COUNT(*)
|
||||
FROM {$wpdb->postmeta} pm
|
||||
INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
|
||||
WHERE p.post_type = %s AND pm.meta_key = %s AND p.post_status = 'trash'",
|
||||
$post_type,
|
||||
$meta_key
|
||||
)
|
||||
);
|
||||
$signals['trash_ratio'] = $trash_count / $row_count;
|
||||
}
|
||||
|
||||
return $signals;
|
||||
}
|
||||
|
||||
/**
|
||||
* Score each zone based on collected signals.
|
||||
*
|
||||
* @param array $signals Signal data from collect_signals().
|
||||
* @return array{hot: float, warm: float, cold: float, archive: float} Zone scores.
|
||||
*/
|
||||
private static function score_zones( array $signals ): array {
|
||||
$scores = array(
|
||||
'hot' => 0.0,
|
||||
'warm' => 0.0,
|
||||
'cold' => 0.0,
|
||||
'archive' => 0.0,
|
||||
);
|
||||
|
||||
// --- Hot signals ---
|
||||
// Short, numeric values are great for indexing.
|
||||
if ( $signals['avg_length'] < 50 ) {
|
||||
$scores['hot'] += 0.3;
|
||||
}
|
||||
if ( $signals['numeric_ratio'] > 0.8 ) {
|
||||
$scores['hot'] += 0.3;
|
||||
}
|
||||
// Low cardinality = enum/flag = good for filtering.
|
||||
if ( $signals['distinct_values'] > 0 && $signals['distinct_values'] <= 20 ) {
|
||||
$scores['hot'] += 0.2;
|
||||
}
|
||||
// HivePress prefix = likely a search field.
|
||||
if ( 'hivepress' === $signals['prefix'] && $signals['avg_length'] < 100 ) {
|
||||
$scores['hot'] += 0.2;
|
||||
}
|
||||
|
||||
// --- Warm signals ---
|
||||
// Transient prefix is a clear warm signal.
|
||||
if ( 'transient' === $signals['prefix'] ) {
|
||||
$scores['warm'] += 0.8;
|
||||
}
|
||||
// Internal prefix + short values.
|
||||
if ( 'internal' === $signals['prefix'] && $signals['avg_length'] < 100 ) {
|
||||
$scores['warm'] += 0.2;
|
||||
}
|
||||
|
||||
// --- Cold signals ---
|
||||
// Long text/JSON blobs are cold candidates.
|
||||
if ( $signals['avg_length'] > 200 ) {
|
||||
$scores['cold'] += 0.4;
|
||||
}
|
||||
if ( $signals['is_json'] || $signals['is_serialized'] ) {
|
||||
$scores['cold'] += 0.3;
|
||||
}
|
||||
// High cardinality + long values = display/profile data.
|
||||
if ( $signals['distinct_values'] > 50 && $signals['avg_length'] > 100 ) {
|
||||
$scores['cold'] += 0.2;
|
||||
}
|
||||
// HivePress prefix + long values = description field.
|
||||
if ( 'hivepress' === $signals['prefix'] && $signals['avg_length'] > 100 ) {
|
||||
$scores['cold'] += 0.2;
|
||||
}
|
||||
|
||||
// --- Archive signals ---
|
||||
// High trash ratio = archive candidate.
|
||||
if ( $signals['trash_ratio'] > 0.5 ) {
|
||||
$scores['archive'] += 0.6;
|
||||
} elseif ( $signals['trash_ratio'] > 0.2 ) {
|
||||
$scores['archive'] += 0.3;
|
||||
}
|
||||
|
||||
// Normalize: ensure at least one zone has a score.
|
||||
$max = max( $scores );
|
||||
if ( 0.0 === $max ) {
|
||||
// Default to cold for unknown patterns.
|
||||
$scores['cold'] = 0.1;
|
||||
}
|
||||
|
||||
return $scores;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build human-readable reasons for the zone suggestion.
|
||||
*
|
||||
* @param array $signals Signal data from collect_signals().
|
||||
* @param string $zone Suggested zone name.
|
||||
* @return array Array of human-readable reason strings.
|
||||
*/
|
||||
private static function build_reasons( array $signals, string $zone ): array {
|
||||
$reasons = array();
|
||||
|
||||
switch ( $zone ) {
|
||||
case 'hot':
|
||||
if ( $signals['avg_length'] < 50 ) {
|
||||
$reasons[] = sprintf( 'Short values (avg %d chars) — efficient for indexing', $signals['avg_length'] );
|
||||
}
|
||||
if ( $signals['numeric_ratio'] > 0.8 ) {
|
||||
$reasons[] = sprintf( '%.0f%% numeric values — ideal for range queries', $signals['numeric_ratio'] * 100 );
|
||||
}
|
||||
if ( $signals['distinct_values'] <= 20 ) {
|
||||
$reasons[] = sprintf( 'Low cardinality (%d distinct values) — good for filtering', $signals['distinct_values'] );
|
||||
}
|
||||
break;
|
||||
|
||||
case 'warm':
|
||||
if ( 'transient' === $signals['prefix'] ) {
|
||||
$reasons[] = 'Transient prefix detected — ephemeral data with natural TTL';
|
||||
}
|
||||
break;
|
||||
|
||||
case 'cold':
|
||||
if ( $signals['avg_length'] > 200 ) {
|
||||
$reasons[] = sprintf( 'Long values (avg %d chars) — display/profile data', $signals['avg_length'] );
|
||||
}
|
||||
if ( $signals['is_json'] ) {
|
||||
$reasons[] = 'JSON structure detected — good for blob storage';
|
||||
}
|
||||
if ( $signals['is_serialized'] ) {
|
||||
$reasons[] = 'Serialized data — good for blob storage';
|
||||
}
|
||||
break;
|
||||
|
||||
case 'archive':
|
||||
if ( $signals['trash_ratio'] > 0.2 ) {
|
||||
$reasons[] = sprintf( '%.0f%% of entries belong to trashed posts', $signals['trash_ratio'] * 100 );
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if ( empty( $reasons ) ) {
|
||||
$reasons[] = 'Default classification based on overall signal pattern';
|
||||
}
|
||||
|
||||
return $reasons;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a meta_key is a WordPress internal key that should be skipped.
|
||||
*
|
||||
* @param string $meta_key Meta key to check.
|
||||
* @return bool True if the key is a WordPress internal key.
|
||||
*/
|
||||
private static function is_wp_internal( string $meta_key ): bool {
|
||||
$skip = array(
|
||||
'_edit_lock',
|
||||
'_edit_last',
|
||||
'_wp_page_template',
|
||||
'_wp_old_slug',
|
||||
'_wp_trash_meta_time',
|
||||
'_wp_trash_meta_status',
|
||||
'_wp_desired_post_slug',
|
||||
'_thumbnail_id',
|
||||
'_encloseme',
|
||||
'_pingme',
|
||||
);
|
||||
|
||||
return in_array( $meta_key, $skip, true );
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user