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:
2026-07-31 05:06:36 +08:00
commit d36bb954d1
206 changed files with 66538 additions and 0 deletions
+396
View File
@@ -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 );
}
}