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,266 @@
|
||||
<?php
|
||||
// phpcs:ignore WPDO.AntiEAV -- platform diagnostic: raw meta inspection for FSM state advisor
|
||||
/**
|
||||
* TMDO_FSM_Advisor — Recommends the next FSM state per module (v2.4.0 M12).
|
||||
*
|
||||
* Reads:
|
||||
* - Current state via TMDO_Feature_Flags::get( $module )
|
||||
* - Time-in-current-state via wp_options.wpdo_fsm_state_entered (set by
|
||||
* TMDO_FSM_Guard::record_entry() since v2.2.0 M2)
|
||||
* - shadow_diffs ratio per module from wp_wpdo_shadow_diffs (last 24h)
|
||||
*
|
||||
* Emits one of:
|
||||
* - PROMOTE — current state has cooked long enough + low divergence; safe to advance
|
||||
* - WAIT — needs more soak time, returns days remaining
|
||||
* - REVIEW — divergence too high; manual investigation needed before promoting
|
||||
* - ROLLBACK — error budget exceeded; suggest rewind to idle
|
||||
* - HOLD — module already idle or complete; nothing to do
|
||||
*
|
||||
* Designed to be advice — never auto-acts. Surfaces in Migration tab as a
|
||||
* panel beside each module row.
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* FSM advisor — stateless static API with structured `advise()` return.
|
||||
*/
|
||||
class TMDO_FSM_Advisor {
|
||||
|
||||
/** Minimum soak days per state before advisor allows promotion. */
|
||||
public const MIN_SOAK_DAYS = array(
|
||||
'idle' => 0,
|
||||
'dual_write' => 1,
|
||||
'backfill' => 1,
|
||||
'verify' => 7,
|
||||
'cutover' => 1,
|
||||
'cleanup' => 3,
|
||||
'complete' => 0, // terminal — never promote.
|
||||
);
|
||||
|
||||
/** Max divergence ratio in `verify` state before advisor refuses to promote. */
|
||||
public const VERIFY_MAX_DIVERGENCE_RATIO = 0.001; // 0.1%
|
||||
|
||||
/**
|
||||
* Build advice for a single module.
|
||||
*
|
||||
* @param string $module Module slug.
|
||||
* @return array {action:string, next_state?:string, days_remaining?:int,
|
||||
* reason:string, level:'info'|'warn'|'critical', metrics:array}
|
||||
*/
|
||||
public static function advise( string $module ): array {
|
||||
if ( ! class_exists( 'TMDO_Feature_Flags' ) ) {
|
||||
return self::stub( 'unavailable', 'Feature_Flags 未載入' );
|
||||
}
|
||||
$state = TMDO_Feature_Flags::get( $module );
|
||||
|
||||
// Terminal cases.
|
||||
if ( 'idle' === $state ) {
|
||||
return array(
|
||||
'action' => 'HOLD',
|
||||
'level' => 'info',
|
||||
'reason' => __( 'Module 處於 idle,無需建議。如要啟用請先讀 SOP。', '2meet-data-optimizer' ),
|
||||
'metrics' => array( 'state' => $state ),
|
||||
);
|
||||
}
|
||||
if ( 'complete' === $state ) {
|
||||
return array(
|
||||
'action' => 'HOLD',
|
||||
'level' => 'info',
|
||||
'reason' => __( 'Module 已 complete,反 EAV 完成。', '2meet-data-optimizer' ),
|
||||
'metrics' => array( 'state' => $state ),
|
||||
);
|
||||
}
|
||||
|
||||
$days_in_state = self::days_in_state( $module );
|
||||
$min_soak = self::MIN_SOAK_DAYS[ $state ] ?? 1;
|
||||
|
||||
$metrics = array(
|
||||
'state' => $state,
|
||||
'days_in_state' => $days_in_state,
|
||||
'min_soak_days' => $min_soak,
|
||||
);
|
||||
|
||||
// Verify state requires divergence ratio check.
|
||||
if ( 'verify' === $state ) {
|
||||
$div = self::shadow_diff_ratio( $module );
|
||||
$metrics['shadow_diff_ratio_24h'] = $div['ratio'];
|
||||
$metrics['shadow_diff_count_24h'] = $div['count'];
|
||||
$metrics['shadow_diff_total_24h'] = $div['total'];
|
||||
|
||||
if ( $div['count'] > 0 && $div['ratio'] > self::VERIFY_MAX_DIVERGENCE_RATIO ) {
|
||||
return array(
|
||||
'action' => 'REVIEW',
|
||||
'next_state' => null,
|
||||
'level' => 'warn',
|
||||
'reason' => sprintf(
|
||||
/* translators: 1: ratio, 2: max allowed */
|
||||
__( 'Verify 期間 shadow_diffs 比率 %1$.2f%% 超過上限 %2$.2f%%;建議手動 review wp_wpdo_shadow_diffs 確認分歧來源後再決定。', '2meet-data-optimizer' ),
|
||||
$div['ratio'] * 100,
|
||||
self::VERIFY_MAX_DIVERGENCE_RATIO * 100
|
||||
),
|
||||
'metrics' => $metrics,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Soak time check.
|
||||
if ( null !== $days_in_state && $days_in_state < $min_soak ) {
|
||||
$days_remaining = $min_soak - $days_in_state;
|
||||
return array(
|
||||
'action' => 'WAIT',
|
||||
'days_remaining' => $days_remaining,
|
||||
'level' => 'info',
|
||||
'reason' => sprintf(
|
||||
/* translators: 1: days left, 2: state name, 3: min days */
|
||||
__( '再等 %1$d 天即可推進。當前 %2$s 狀態需 ≥ %3$d 天 soak。', '2meet-data-optimizer' ),
|
||||
$days_remaining,
|
||||
$state,
|
||||
$min_soak
|
||||
),
|
||||
'metrics' => $metrics,
|
||||
);
|
||||
}
|
||||
|
||||
// Promotion candidate.
|
||||
$next = self::next_state( $state );
|
||||
if ( null === $next ) {
|
||||
return array(
|
||||
'action' => 'HOLD',
|
||||
'level' => 'info',
|
||||
'reason' => __( '當前狀態為 terminal — 無下一步建議。', '2meet-data-optimizer' ),
|
||||
'metrics' => $metrics,
|
||||
);
|
||||
}
|
||||
return array(
|
||||
'action' => 'PROMOTE',
|
||||
'next_state' => $next,
|
||||
'level' => 'info',
|
||||
'reason' => sprintf(
|
||||
/* translators: 1: from, 2: to, 3: days */
|
||||
__( '可推進:%1$s → %2$s(已 soak %3$d 天)。', '2meet-data-optimizer' ),
|
||||
$state,
|
||||
$next,
|
||||
$days_in_state
|
||||
),
|
||||
'metrics' => $metrics,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build advice for every known module. Returns map module → advice.
|
||||
*
|
||||
* @return array<string,array>
|
||||
*/
|
||||
public static function advise_all(): array {
|
||||
if ( ! class_exists( 'TMDO_Feature_Flags' ) ) {
|
||||
return array();
|
||||
}
|
||||
$out = array();
|
||||
foreach ( TMDO_Feature_Flags::all() as $module => $_state ) {
|
||||
$out[ $module ] = self::advise( $module );
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
// ─── private ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Return days since the module entered its current state.
|
||||
*
|
||||
* @param string $module Module slug.
|
||||
* @return int|null Days since entering current state, or null if unknown.
|
||||
*/
|
||||
private static function days_in_state( string $module ): ?int {
|
||||
$entered = (array) get_option( 'wpdo_fsm_state_entered', array() );
|
||||
if ( ! isset( $entered[ $module ]['entered_at'] ) ) {
|
||||
return null;
|
||||
}
|
||||
$ts = strtotime( (string) $entered[ $module ]['entered_at'] . ' UTC' );
|
||||
if ( false === $ts || $ts <= 0 ) {
|
||||
return null;
|
||||
}
|
||||
return (int) floor( ( time() - $ts ) / DAY_IN_SECONDS );
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the shadow-diff divergence ratio for a module over the last 24 hours.
|
||||
*
|
||||
* @param string $module Module slug.
|
||||
* @return array {ratio:float, count:int, total:int}
|
||||
*/
|
||||
private static function shadow_diff_ratio( string $module ): array { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found -- $module reserved for per-module entity_type routing (future)
|
||||
global $wpdb;
|
||||
$out = array(
|
||||
'ratio' => 0.0,
|
||||
'count' => 0,
|
||||
'total' => 0,
|
||||
);
|
||||
$table = $wpdb->prefix . 'wpdo_shadow_diffs';
|
||||
$exists = (int) $wpdb->get_var(
|
||||
$wpdb->prepare( // phpcs:ignore WordPress.DB
|
||||
'SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s',
|
||||
$table
|
||||
)
|
||||
);
|
||||
if ( 0 === $exists ) {
|
||||
return $out;
|
||||
}
|
||||
// Module → entity_type mapping is loose — most HPCT modules map to 'post'.
|
||||
// Use entity_type='post' as default proxy; downstream `level=warn` is
|
||||
// honest about uncertainty.
|
||||
$count = (int) $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT COUNT(*) FROM `{$table}` WHERE entity_type = %s AND ts >= DATE_SUB(UTC_TIMESTAMP(), INTERVAL 24 HOUR)", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- {$table} is $wpdb->prefix . 'wpdo_shadow_diffs' (no user input)
|
||||
'post'
|
||||
)
|
||||
);
|
||||
$total = (int) $wpdb->get_var( // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- no variables; table name is a WP core property
|
||||
"SELECT COUNT(*) FROM `{$wpdb->postmeta}` WHERE meta_id > 0"
|
||||
);
|
||||
$out['count'] = $count;
|
||||
$out['total'] = max( 1, $total );
|
||||
$out['ratio'] = $count / $out['total'];
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Forward graph (mirrors TMDO_FSM_Guard::FORWARD_GRAPH but without the
|
||||
* cycle check — advisor only suggests the canonical next step).
|
||||
*
|
||||
* @param string $state Current state.
|
||||
* @return string|null Next state, or null when terminal.
|
||||
*/
|
||||
private static function next_state( string $state ): ?string {
|
||||
$map = array(
|
||||
'idle' => 'dual_write',
|
||||
'dual_write' => 'backfill',
|
||||
'backfill' => 'verify',
|
||||
'verify' => 'cutover',
|
||||
'cutover' => 'cleanup',
|
||||
'cleanup' => 'complete',
|
||||
'complete' => null,
|
||||
);
|
||||
return $map[ $state ] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stub advice when prerequisites are missing.
|
||||
*
|
||||
* @param string $action Reason code.
|
||||
* @param string $reason Human reason.
|
||||
* @return array
|
||||
*/
|
||||
private static function stub( string $action, string $reason ): array {
|
||||
return array(
|
||||
'action' => strtoupper( $action ),
|
||||
'level' => 'info',
|
||||
'reason' => $reason,
|
||||
'metrics' => array(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
<?php
|
||||
/**
|
||||
* TMDO_FSM_Automator — Opt-in automatic execution of FSM_Advisor PROMOTE
|
||||
* recommendations (v2.5.0 M13).
|
||||
*
|
||||
* Default OFF. When admin enables, runs daily at 04:30 UTC. For each module:
|
||||
* - Calls TMDO_FSM_Advisor::advise()
|
||||
* - Only auto-promotes when ALL 4 conditions true:
|
||||
* 1. wpdo_automator_enabled = 1
|
||||
* 2. module not in wpdo_automator_blacklist
|
||||
* 3. TMDO_Health_Cron::get_last_run() critical_count = 0 in last 7 days
|
||||
* (cool-off — system is healthy)
|
||||
* 4. ≥ 24h since this module's last automated promotion
|
||||
* - Destructive transitions (cutover→cleanup, cleanup→complete) are
|
||||
* NEVER automated — admin must manually invoke.
|
||||
*
|
||||
* Audit trail: every automator action writes wp_wpdo_audit op='automator_promoted'
|
||||
* + fires action `wpdo/automator_promoted` for downstream subscribers.
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Automator — stateless static API.
|
||||
*/
|
||||
class TMDO_FSM_Automator {
|
||||
|
||||
public const OPT_ENABLED = 'wpdo_automator_enabled';
|
||||
public const OPT_BLACKLIST = 'wpdo_automator_blacklist';
|
||||
public const OPT_LAST_ACTION = 'wpdo_automator_last_action';
|
||||
|
||||
/** Cool-off window after critical health alert. */
|
||||
private const COOL_OFF_DAYS = 7;
|
||||
|
||||
/** Minimum interval between automated promotions of the same module. */
|
||||
private const MIN_INTERVAL_HOURS = 24;
|
||||
|
||||
/** Transitions that are never auto-executed (destructive). */
|
||||
private const FORBIDDEN_TRANSITIONS = array(
|
||||
array( 'verify', 'cutover' ), // cutover starts reading from custom — needs manual sign-off.
|
||||
array( 'cutover', 'cleanup' ), // cleanup purges wp_*meta — irreversible without snapshot.
|
||||
array( 'cleanup', 'complete' ), // complete = no fallback path.
|
||||
);
|
||||
|
||||
/**
|
||||
* Cron handler.
|
||||
*
|
||||
* @return array {ok:bool, executed:int, skipped:int, errors:array}
|
||||
*/
|
||||
public static function run(): array {
|
||||
$result = array(
|
||||
'ok' => true,
|
||||
'executed' => 0,
|
||||
'skipped' => 0,
|
||||
'errors' => array(),
|
||||
'actions' => array(),
|
||||
);
|
||||
|
||||
// Pre-flight: enabled?
|
||||
if ( ! self::is_enabled() ) {
|
||||
$result['ok'] = false;
|
||||
$result['errors'][] = 'automator disabled';
|
||||
return $result;
|
||||
}
|
||||
|
||||
// Pre-flight: cool-off?
|
||||
if ( ! self::cool_off_clear() ) {
|
||||
$result['ok'] = false;
|
||||
$result['errors'][] = 'cool-off active (critical health in last ' . self::COOL_OFF_DAYS . ' days)';
|
||||
return $result;
|
||||
}
|
||||
|
||||
// Iterate modules.
|
||||
if ( ! class_exists( 'TMDO_Feature_Flags' ) || ! class_exists( 'TMDO_FSM_Advisor' ) ) {
|
||||
$result['ok'] = false;
|
||||
$result['errors'][] = 'dependencies missing';
|
||||
return $result;
|
||||
}
|
||||
|
||||
$blacklist = (array) get_option( self::OPT_BLACKLIST, array() );
|
||||
$last_actions = (array) get_option( self::OPT_LAST_ACTION, array() );
|
||||
|
||||
foreach ( TMDO_Feature_Flags::all() as $module => $current_state ) {
|
||||
// Blacklisted?
|
||||
if ( in_array( $module, $blacklist, true ) ) {
|
||||
++$result['skipped'];
|
||||
continue;
|
||||
}
|
||||
// Get advisor recommendation.
|
||||
$advice = TMDO_FSM_Advisor::advise( $module );
|
||||
if ( 'PROMOTE' !== ( $advice['action'] ?? '' ) || 'info' !== ( $advice['level'] ?? '' ) ) {
|
||||
++$result['skipped'];
|
||||
continue;
|
||||
}
|
||||
$next_state = (string) ( $advice['next_state'] ?? '' );
|
||||
if ( '' === $next_state ) {
|
||||
++$result['skipped'];
|
||||
continue;
|
||||
}
|
||||
// Forbidden destructive transition?
|
||||
if ( self::is_forbidden_transition( $current_state, $next_state ) ) {
|
||||
++$result['skipped'];
|
||||
continue;
|
||||
}
|
||||
// Recent action?
|
||||
if ( ! self::interval_clear( $module, $last_actions ) ) {
|
||||
++$result['skipped'];
|
||||
continue;
|
||||
}
|
||||
// All checks passed — execute.
|
||||
$set_result = TMDO_Feature_Flags::set( $module, $next_state );
|
||||
if ( true === $set_result ) {
|
||||
++$result['executed'];
|
||||
$result['actions'][] = array(
|
||||
'module' => $module,
|
||||
'from' => $current_state,
|
||||
'to' => $next_state,
|
||||
);
|
||||
$last_actions[ $module ] = gmdate( 'Y-m-d H:i:s' );
|
||||
if ( class_exists( 'TMDO_Logger' ) ) {
|
||||
TMDO_Logger::info(
|
||||
'automator_promoted',
|
||||
array(
|
||||
'module' => $module,
|
||||
'from' => $current_state,
|
||||
'to' => $next_state,
|
||||
)
|
||||
);
|
||||
}
|
||||
do_action( 'wpdo/automator_promoted', $module, $current_state, $next_state );
|
||||
} else {
|
||||
$result['errors'][] = "{$module}: " . ( $set_result instanceof \WP_Error ? $set_result->get_error_code() : 'unknown' );
|
||||
}
|
||||
}
|
||||
|
||||
update_option( self::OPT_LAST_ACTION, $last_actions, false );
|
||||
return $result;
|
||||
}
|
||||
|
||||
// ─── settings accessors ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Return whether the automator is enabled.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public static function is_enabled(): bool {
|
||||
return '1' === (string) get_option( self::OPT_ENABLED, '0' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the list of blacklisted module slugs.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public static function blacklist(): array {
|
||||
return (array) get_option( self::OPT_BLACKLIST, array() );
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the map of module → last automated action timestamp.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public static function last_actions(): array {
|
||||
return (array) get_option( self::OPT_LAST_ACTION, array() );
|
||||
}
|
||||
|
||||
// ─── private helpers ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Cool-off: false when there's been a critical health alert in last N days.
|
||||
*
|
||||
* @return bool true = OK to act, false = blocked.
|
||||
*/
|
||||
private static function cool_off_clear(): bool {
|
||||
if ( ! class_exists( 'TMDO_Health_Cron' ) ) {
|
||||
return true; // No data — assume OK.
|
||||
}
|
||||
$last = TMDO_Health_Cron::get_last_run();
|
||||
if ( ! is_array( $last ) ) {
|
||||
return true;
|
||||
}
|
||||
$crit = (int) ( $last['critical_count'] ?? 0 );
|
||||
if ( 0 === $crit ) {
|
||||
return true;
|
||||
}
|
||||
$ts = isset( $last['ran_at'] ) ? strtotime( (string) $last['ran_at'] . ' UTC' ) : 0;
|
||||
if ( $ts <= 0 ) {
|
||||
return true;
|
||||
}
|
||||
// Critical alert exists; cool-off if within window.
|
||||
return ( time() - $ts ) > ( self::COOL_OFF_DAYS * DAY_IN_SECONDS );
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-module interval: ≥ 24h since last automated action on this module.
|
||||
*
|
||||
* @param string $module Module slug.
|
||||
* @param array $last_actions Map of module → timestamp.
|
||||
* @return bool true = OK to act.
|
||||
*/
|
||||
private static function interval_clear( string $module, array $last_actions ): bool {
|
||||
$last = (string) ( $last_actions[ $module ] ?? '' );
|
||||
if ( '' === $last ) {
|
||||
return true;
|
||||
}
|
||||
$ts = strtotime( $last . ' UTC' );
|
||||
if ( $ts <= 0 ) {
|
||||
return true;
|
||||
}
|
||||
return ( time() - $ts ) >= ( self::MIN_INTERVAL_HOURS * HOUR_IN_SECONDS );
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if (from, to) is in FORBIDDEN_TRANSITIONS.
|
||||
*
|
||||
* @param string $from Current state.
|
||||
* @param string $to Target state.
|
||||
* @return bool
|
||||
*/
|
||||
private static function is_forbidden_transition( string $from, string $to ): bool {
|
||||
foreach ( self::FORBIDDEN_TRANSITIONS as $pair ) {
|
||||
if ( $pair[0] === $from && $pair[1] === $to ) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,381 @@
|
||||
<?php
|
||||
/**
|
||||
* TMDO_Module_Detector — Smart module availability detection (v2.5.0 M16).
|
||||
*
|
||||
* For each registered module (see TMDO_Module_Rules), evaluates:
|
||||
* 1. Are required plugins active (Compatibility::is_*_active)?
|
||||
* 2. Does the required post_type exist + meet min_post_count?
|
||||
* 3. (zone modules) Does Classifier confidence exceed min_classifier_confidence?
|
||||
* 4. Is the module already in a non-idle state (no point recommending)?
|
||||
*
|
||||
* Output per module: {available, confidence, recommendation, reasons,
|
||||
* blockers, current_state, suggested_action}.
|
||||
*
|
||||
* Caching: detect_all() is heavy (queries postmeta, runs classifier).
|
||||
* Daily health-cron writes to wp_options.wpdo_module_suggestions; admin UI
|
||||
* reads from there for fast widget rendering.
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detection engine — stateless static API.
|
||||
*/
|
||||
class TMDO_Module_Detector {
|
||||
|
||||
/** WP options key holding cached results. */
|
||||
public const OPTION_CACHE = 'wpdo_module_suggestions';
|
||||
|
||||
/** Transient key for short-term cache (1 hour). */
|
||||
private const TRANSIENT = 'wpdo_module_detector_results';
|
||||
|
||||
/** TTL for transient cache. */
|
||||
private const TRANSIENT_TTL = 3600;
|
||||
|
||||
/**
|
||||
* Detect every registered module. Cached for 1 hour via transient.
|
||||
*
|
||||
* @param bool $force_refresh Bypass transient cache.
|
||||
* @return array<string,array> Module slug → result.
|
||||
*/
|
||||
public static function detect_all( bool $force_refresh = false ): array {
|
||||
if ( ! $force_refresh ) {
|
||||
$cached = get_transient( self::TRANSIENT );
|
||||
if ( is_array( $cached ) ) {
|
||||
return $cached;
|
||||
}
|
||||
}
|
||||
$out = array();
|
||||
foreach ( TMDO_Module_Rules::known_modules() as $module ) {
|
||||
$out[ $module ] = self::detect_one( $module );
|
||||
}
|
||||
set_transient( self::TRANSIENT, $out, self::TRANSIENT_TTL );
|
||||
// Persist to wp_options for the dashboard widget (no autoload).
|
||||
update_option(
|
||||
self::OPTION_CACHE,
|
||||
array(
|
||||
'results' => $out,
|
||||
'generated_at' => gmdate( 'Y-m-d H:i:s' ),
|
||||
),
|
||||
false
|
||||
);
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect a single module.
|
||||
*
|
||||
* @param string $module Module slug.
|
||||
* @return array See class doc for shape.
|
||||
*/
|
||||
public static function detect_one( string $module ): array {
|
||||
$rule = TMDO_Module_Rules::for_module( $module );
|
||||
if ( null === $rule ) {
|
||||
return self::stub( $module, 'no_rule' );
|
||||
}
|
||||
|
||||
$reasons = array();
|
||||
$blockers = array();
|
||||
$confidence = 0.0;
|
||||
$current_state = class_exists( 'TMDO_Feature_Flags' ) ? TMDO_Feature_Flags::get( $module ) : 'idle';
|
||||
|
||||
// 0. Already non-idle? Block recommendation.
|
||||
if ( 'idle' !== $current_state ) {
|
||||
$blockers[] = sprintf( '⚠️ Module 已在 %s 狀態(不需重複推薦)', $current_state );
|
||||
return self::build(
|
||||
$module,
|
||||
false,
|
||||
0.0,
|
||||
'skip',
|
||||
$reasons,
|
||||
$blockers,
|
||||
$current_state
|
||||
);
|
||||
}
|
||||
|
||||
// 1. Plugin compatibility check.
|
||||
$compat_required = (array) ( $rule['compat_required'] ?? array() );
|
||||
if ( ! empty( $compat_required ) ) {
|
||||
$missing = self::check_compat( $compat_required );
|
||||
if ( ! empty( $missing ) ) {
|
||||
$blockers[] = '⚠️ 需要 plugin 啟用:' . implode( ', ', $missing );
|
||||
return self::build( $module, false, 0.0, 'skip', $reasons, $blockers, $current_state );
|
||||
}
|
||||
$reasons[] = '✅ 必要 plugin 已啟用:' . implode( ', ', $compat_required );
|
||||
$confidence += 0.3;
|
||||
}
|
||||
|
||||
// 2. Required post_type + row count.
|
||||
$post_type = (string) ( $rule['post_type_required'] ?? '' );
|
||||
$min_count = (int) ( $rule['min_post_count'] ?? 0 );
|
||||
if ( '' !== $post_type ) {
|
||||
$count = self::post_type_count( $post_type );
|
||||
if ( $count < $min_count ) {
|
||||
$blockers[] = sprintf(
|
||||
'⚠️ %s post_type 只有 %s 行(需要 ≥ %s)',
|
||||
$post_type,
|
||||
number_format_i18n( $count ),
|
||||
number_format_i18n( $min_count )
|
||||
);
|
||||
return self::build( $module, false, $confidence, 'wait', $reasons, $blockers, $current_state );
|
||||
}
|
||||
$reasons[] = sprintf(
|
||||
'✅ %s post_type 有 %s 行(門檻 %s)',
|
||||
$post_type,
|
||||
number_format_i18n( $count ),
|
||||
number_format_i18n( $min_count )
|
||||
);
|
||||
$confidence += 0.4;
|
||||
}
|
||||
|
||||
// 3. Archive-specific: trashed count.
|
||||
if ( 'archive' === $module ) {
|
||||
$min_trash = (int) ( $rule['min_trash_count'] ?? 0 );
|
||||
$trash_count = self::trashed_post_count();
|
||||
if ( $trash_count < $min_trash ) {
|
||||
$blockers[] = sprintf( '⚠️ trashed posts 只有 %d 個(需要 ≥ %d)', $trash_count, $min_trash );
|
||||
return self::build( $module, false, $confidence, 'wait', $reasons, $blockers, $current_state );
|
||||
}
|
||||
$reasons[] = sprintf( '✅ trashed posts 有 %d 個', $trash_count );
|
||||
$confidence += 0.3;
|
||||
}
|
||||
|
||||
// 4. Classifier consultation (zone modules).
|
||||
if ( ! empty( $rule['consult_classifier'] ) && '' !== $post_type && class_exists( 'TMDO_Zone_Classifier' ) ) {
|
||||
$min_conf = (float) ( $rule['min_classifier_confidence'] ?? 0.6 );
|
||||
$cls_score = self::classifier_score( $post_type, $module );
|
||||
if ( $cls_score < $min_conf ) {
|
||||
$blockers[] = sprintf(
|
||||
'⚠️ Classifier 對 %s 的 %s zone confidence 僅 %.2f(需 ≥ %.2f)',
|
||||
$post_type,
|
||||
self::module_to_zone( $module ),
|
||||
$cls_score,
|
||||
$min_conf
|
||||
);
|
||||
return self::build( $module, false, $confidence, 'wait', $reasons, $blockers, $current_state );
|
||||
}
|
||||
$reasons[] = sprintf( '✅ Classifier confidence %.2f(門檻 %.2f)', $cls_score, $min_conf );
|
||||
$confidence = min( 1.0, $confidence + $cls_score * 0.3 );
|
||||
}
|
||||
|
||||
// 5. Priority bonus: warm/archive recommended_first.
|
||||
// v2.5.0 M16 polish: bump from +0.2 → +0.5 so a low-risk module like
|
||||
// `warm` (no compat / no post_type gate) crosses the actionable
|
||||
// threshold (0.5) on a fresh install — Setup Wizard / Dashboard widget
|
||||
// can surface it without admin chasing config.
|
||||
if ( 'recommended_first' === ( $rule['priority'] ?? '' ) ) {
|
||||
$confidence = min( 1.0, $confidence + 0.5 );
|
||||
$reasons[] = '⭐ 入門首選(低風險)';
|
||||
}
|
||||
|
||||
// Cap confidence.
|
||||
$confidence = min( 1.0, max( 0.0, $confidence ) );
|
||||
|
||||
return self::build( $module, true, $confidence, 'enable', $reasons, $blockers, $current_state );
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter detect_all() down to actionable enable-recommendations
|
||||
* above a confidence threshold.
|
||||
*
|
||||
* @param float $min_confidence Threshold (0..1, default 0.5).
|
||||
* @return array<string,array>
|
||||
*/
|
||||
public static function get_actionable( float $min_confidence = 0.5 ): array {
|
||||
$all = self::detect_all();
|
||||
$out = array();
|
||||
foreach ( $all as $module => $r ) {
|
||||
if ( ! empty( $r['available'] )
|
||||
&& 'enable' === ( $r['recommendation'] ?? '' )
|
||||
&& (float) ( $r['confidence'] ?? 0 ) >= $min_confidence ) {
|
||||
$out[ $module ] = $r;
|
||||
}
|
||||
}
|
||||
// Sort by confidence desc.
|
||||
uasort( $out, static fn( $a, $b ) => (float) $b['confidence'] <=> (float) $a['confidence'] );
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the cached results from wp_options (for dashboard widget — no live query).
|
||||
*
|
||||
* @return array {results:array, generated_at:string}|null
|
||||
*/
|
||||
public static function get_cached(): ?array {
|
||||
$v = get_option( self::OPTION_CACHE );
|
||||
return is_array( $v ) ? $v : null;
|
||||
}
|
||||
|
||||
// ─── private helpers ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Build a result envelope.
|
||||
*
|
||||
* @param string $module Module slug.
|
||||
* @param bool $available Whether module passes all checks.
|
||||
* @param float $confidence 0..1.
|
||||
* @param string $recommendation 'enable'|'wait'|'skip'.
|
||||
* @param array $reasons Positive findings.
|
||||
* @param array $blockers Negative findings.
|
||||
* @param string $current_state Current FSM state.
|
||||
* @return array
|
||||
*/
|
||||
private static function build( string $module, bool $available, float $confidence, string $recommendation, array $reasons, array $blockers, string $current_state ): array {
|
||||
$rule = TMDO_Module_Rules::for_module( $module ) ?? array();
|
||||
return array(
|
||||
'module' => $module,
|
||||
'available' => $available,
|
||||
'confidence' => round( $confidence, 2 ),
|
||||
'recommendation' => $recommendation,
|
||||
'reasons' => $reasons,
|
||||
'blockers' => $blockers,
|
||||
'description' => (string) ( $rule['description'] ?? '' ),
|
||||
'current_state' => $current_state,
|
||||
'suggested_action' => $available && 'enable' === $recommendation
|
||||
? array(
|
||||
'type' => 'set_state',
|
||||
'module' => $module,
|
||||
'to_state' => 'dual_write',
|
||||
'cli' => "wp wpdo mode-set {$module} dual_write",
|
||||
)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a stub result envelope for modules that cannot be evaluated.
|
||||
*
|
||||
* @param string $module Module slug.
|
||||
* @param string $reason Reason code or human message.
|
||||
* @return array
|
||||
*/
|
||||
private static function stub( string $module, string $reason ): array {
|
||||
return array(
|
||||
'module' => $module,
|
||||
'available' => false,
|
||||
'confidence' => 0.0,
|
||||
'recommendation' => 'skip',
|
||||
'reasons' => array(),
|
||||
'blockers' => array( $reason ),
|
||||
'description' => '',
|
||||
'current_state' => 'idle',
|
||||
'suggested_action' => null,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find which required plugins are missing.
|
||||
*
|
||||
* @param array $required Plugins required.
|
||||
* @return array<int,string> Missing plugin slugs.
|
||||
*/
|
||||
private static function check_compat( array $required ): array {
|
||||
if ( ! class_exists( 'TMDO_Compatibility' ) ) {
|
||||
return $required;
|
||||
}
|
||||
$missing = array();
|
||||
foreach ( $required as $plugin ) {
|
||||
$active = match ( $plugin ) {
|
||||
'hivepress' => TMDO_Compatibility::is_hivepress_active(),
|
||||
'woocommerce' => TMDO_Compatibility::is_woocommerce_active(),
|
||||
'hpct' => TMDO_Compatibility::is_hpct_active(),
|
||||
'latepoint' => TMDO_Compatibility::is_latepoint_active(),
|
||||
default => false,
|
||||
};
|
||||
if ( ! $active ) {
|
||||
$missing[] = $plugin;
|
||||
}
|
||||
}
|
||||
return $missing;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the published post count for a given post type.
|
||||
*
|
||||
* @param string $post_type Post type slug.
|
||||
* @return int Row count.
|
||||
*/
|
||||
private static function post_type_count( string $post_type ): int {
|
||||
global $wpdb;
|
||||
$cached_key = 'wpdo_pt_count_' . md5( $post_type );
|
||||
$cached = get_transient( $cached_key );
|
||||
if ( false !== $cached ) {
|
||||
return (int) $cached;
|
||||
}
|
||||
$count = (int) $wpdb->get_var(
|
||||
$wpdb->prepare( // phpcs:ignore WordPress.DB
|
||||
"SELECT COUNT(*) FROM `{$wpdb->posts}` WHERE post_type = %s AND post_status NOT IN ('trash','auto-draft')",
|
||||
$post_type
|
||||
)
|
||||
);
|
||||
set_transient( $cached_key, $count, HOUR_IN_SECONDS );
|
||||
return $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Total trashed posts (all post types).
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
private static function trashed_post_count(): int {
|
||||
global $wpdb;
|
||||
$cached = get_transient( 'wpdo_trash_count' );
|
||||
if ( false !== $cached ) {
|
||||
return (int) $cached;
|
||||
}
|
||||
$count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$wpdb->posts}` WHERE post_status = 'trash'" ); // phpcs:ignore WordPress.DB
|
||||
set_transient( 'wpdo_trash_count', $count, HOUR_IN_SECONDS );
|
||||
return $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map module → target zone for Classifier consultation.
|
||||
*
|
||||
* @param string $module Module slug (e.g. hot_hp_listing).
|
||||
* @return string Zone (hot|cold|warm|archive).
|
||||
*/
|
||||
private static function module_to_zone( string $module ): string {
|
||||
if ( str_starts_with( $module, 'hot_' ) ) {
|
||||
return 'hot';
|
||||
}
|
||||
if ( str_starts_with( $module, 'cold_' ) ) {
|
||||
return 'cold';
|
||||
}
|
||||
return $module; // 'warm' / 'archive'.
|
||||
}
|
||||
|
||||
/**
|
||||
* Classifier confidence aggregate for a (post_type, target_zone).
|
||||
*
|
||||
* @param string $post_type Post type.
|
||||
* @param string $module Module slug → mapped to zone.
|
||||
* @return float 0..1 average confidence of meta_keys whose suggested_zone matches.
|
||||
*/
|
||||
private static function classifier_score( string $post_type, string $module ): float {
|
||||
if ( ! class_exists( 'TMDO_Zone_Classifier' ) ) {
|
||||
return 0.0;
|
||||
}
|
||||
$zone = self::module_to_zone( $module );
|
||||
try {
|
||||
$results = TMDO_Zone_Classifier::analyze( $post_type, 50 );
|
||||
} catch ( Throwable $e ) {
|
||||
return 0.0;
|
||||
}
|
||||
if ( ! is_array( $results ) || empty( $results ) ) {
|
||||
return 0.0;
|
||||
}
|
||||
$total = 0.0;
|
||||
$count = 0;
|
||||
foreach ( $results as $field ) {
|
||||
if ( ( $field['suggested_zone'] ?? '' ) === $zone ) {
|
||||
$total += (float) ( $field['confidence'] ?? 0 );
|
||||
++$count;
|
||||
}
|
||||
}
|
||||
return $count > 0 ? $total / $count : 0.0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
<?php
|
||||
/**
|
||||
* TMDO_Module_Rules — Declarative trigger conditions per module (v2.5.0 M16).
|
||||
*
|
||||
* Separates "what makes this module worth enabling" from the detection
|
||||
* machinery (Module_Detector consumes these rules). Each rule declares:
|
||||
* - compat_required:list of plugins that must be active (any of)
|
||||
* - post_type_required:post_type must exist + have rows
|
||||
* - min_post_count:threshold for post_type row count
|
||||
* - min_trash_count:(archive only) trashed post count threshold
|
||||
* - consult_classifier + min_classifier_confidence:(zone modules only)
|
||||
* - description:human reason shown in UI
|
||||
*
|
||||
* Extensible via filter `wpdo/module_rules` — third-party plugins can register
|
||||
* their own modules and rules without forking this file.
|
||||
*
|
||||
* @package WP_Data_Optimizer
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Static rules registry.
|
||||
*/
|
||||
class TMDO_Module_Rules {
|
||||
|
||||
/**
|
||||
* Default rules for the 15 known modules. Use `get()` to apply filter.
|
||||
*/
|
||||
private const DEFAULT_RULES = array(
|
||||
// ─── HPCT modules(HivePress + HPCT)────────────────────────────
|
||||
'reviews' => array(
|
||||
'compat_required' => array( 'hivepress' ),
|
||||
'post_type_required' => 'hp_review',
|
||||
'min_post_count' => 100,
|
||||
'description' => '若使用 HivePress reviews 且累積評論 ≥ 100 條,啟用後可顯著降低 listing 查詢的 JOIN 成本。',
|
||||
),
|
||||
'messages' => array(
|
||||
'compat_required' => array( 'hivepress' ),
|
||||
'post_type_required' => 'hp_message_thread',
|
||||
'min_post_count' => 50,
|
||||
'description' => 'HivePress messages 累積對話數 ≥ 50 時,啟用 module 可加速 inbox / unread count 查詢。',
|
||||
),
|
||||
'favorites' => array(
|
||||
'compat_required' => array( 'hivepress' ),
|
||||
'post_type_required' => 'hp_favorite',
|
||||
'min_post_count' => 200,
|
||||
'description' => '使用者 favorite 累積 ≥ 200 條時,啟用 module 把 favorite meta 從 wp_postmeta 搬出。',
|
||||
),
|
||||
'memberships' => array(
|
||||
'compat_required' => array( 'hivepress' ),
|
||||
'post_type_required' => 'hp_membership',
|
||||
'min_post_count' => 50,
|
||||
'description' => 'HivePress memberships 啟用且 ≥ 50 條訂閱時,membership 過期檢查會更快。',
|
||||
),
|
||||
'statistics' => array(
|
||||
'compat_required' => array( 'hivepress' ),
|
||||
'post_type_required' => 'hp_listing',
|
||||
'min_post_count' => 500,
|
||||
'description' => 'Listing ≥ 500 條時,view_count / favorite_count 等高頻統計搬到 stats module 可大幅減少 wp_postmeta 寫入。',
|
||||
),
|
||||
'requests' => array(
|
||||
'compat_required' => array( 'hivepress' ),
|
||||
'post_type_required' => 'hp_request',
|
||||
'min_post_count' => 50,
|
||||
'description' => 'HivePress requests / quotes 累積 ≥ 50 條時建議啟用。',
|
||||
),
|
||||
'listing_meta' => array(
|
||||
'compat_required' => array( 'hivepress' ),
|
||||
'post_type_required' => 'hp_listing',
|
||||
'min_post_count' => 100,
|
||||
'description' => '所有 listing meta 集中管理;listing ≥ 100 時可省下大量 postmeta JOIN。',
|
||||
),
|
||||
'wc_orders' => array(
|
||||
'compat_required' => array( 'woocommerce' ),
|
||||
'post_type_required' => 'shop_order',
|
||||
'min_post_count' => 100,
|
||||
'description' => 'WooCommerce 訂單 ≥ 100 筆且未啟用 HPOS 時,建議啟用 module 把 vendor commission 搬到 wp_wpdo_wc_commissions。',
|
||||
),
|
||||
'latepoint' => array(
|
||||
'compat_required' => array( 'latepoint' ),
|
||||
'post_type_required' => null,
|
||||
'description' => 'LatePoint 預約系統啟用時建議開啟,把 booking meta 從 wp_postmeta 搬出。',
|
||||
),
|
||||
|
||||
// ─── Zone modules(任何站皆可,但仍依環境推薦)────────────────────
|
||||
'warm' => array(
|
||||
'compat_required' => array(),
|
||||
'post_type_required' => null,
|
||||
'min_post_count' => 0,
|
||||
'priority' => 'recommended_first',
|
||||
'description' => 'Warm zone 處理 view counts / TTL 暫存;任何站都可啟用,幾乎零風險。',
|
||||
),
|
||||
'archive' => array(
|
||||
'compat_required' => array(),
|
||||
'post_type_required' => null,
|
||||
'min_trash_count' => 50,
|
||||
'description' => '已 trashed posts ≥ 50 個時,啟用 archive module 可釋放 wp_postmeta 空間(自動 gzip 壓縮)。',
|
||||
),
|
||||
'hot_hp_listing' => array(
|
||||
'compat_required' => array( 'hivepress' ),
|
||||
'post_type_required' => 'hp_listing',
|
||||
'min_post_count' => 100,
|
||||
'consult_classifier' => true,
|
||||
'min_classifier_confidence' => 0.6,
|
||||
'description' => '使用 HivePress + listing ≥ 100 條 + Classifier confidence ≥ 0.6 時,把高頻欄位搬到 wp_wpdo_hot_hp_listing 可大幅加速 WP_Query。',
|
||||
),
|
||||
'cold_hp_listing' => array(
|
||||
'compat_required' => array( 'hivepress' ),
|
||||
'post_type_required' => 'hp_listing',
|
||||
'min_post_count' => 100,
|
||||
'consult_classifier' => true,
|
||||
'min_classifier_confidence' => 0.5,
|
||||
'description' => '低頻 listing meta(如 settings / preferences)搬到 cold zone,減少 hot path 的 postmeta JOIN。',
|
||||
),
|
||||
'hot_hp_vendor' => array(
|
||||
'compat_required' => array( 'hivepress' ),
|
||||
'post_type_required' => 'hp_vendor',
|
||||
'min_post_count' => 50,
|
||||
'consult_classifier' => true,
|
||||
'min_classifier_confidence' => 0.6,
|
||||
'description' => 'HivePress 商家 ≥ 50 個時,把高頻 vendor meta 搬到 hot zone 可加速 vendor 列表頁。',
|
||||
),
|
||||
'cold_hp_vendor' => array(
|
||||
'compat_required' => array( 'hivepress' ),
|
||||
'post_type_required' => 'hp_vendor',
|
||||
'min_post_count' => 50,
|
||||
'consult_classifier' => true,
|
||||
'min_classifier_confidence' => 0.5,
|
||||
'description' => '低頻 vendor meta 搬到 cold zone。',
|
||||
),
|
||||
);
|
||||
|
||||
/**
|
||||
* Return all rules with filter applied.
|
||||
*
|
||||
* @return array<string,array>
|
||||
*/
|
||||
public static function all(): array {
|
||||
$rules = self::DEFAULT_RULES;
|
||||
if ( function_exists( 'apply_filters' ) ) {
|
||||
$filtered = apply_filters( 'wpdo/module_rules', $rules );
|
||||
if ( is_array( $filtered ) ) {
|
||||
return $filtered;
|
||||
}
|
||||
}
|
||||
return $rules;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return rule for one module.
|
||||
*
|
||||
* @param string $module Module slug.
|
||||
* @return array|null Rule or null when no rule registered.
|
||||
*/
|
||||
public static function for_module( string $module ): ?array {
|
||||
$rules = self::all();
|
||||
return $rules[ $module ] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* List all module slugs that have rules registered.
|
||||
*
|
||||
* @return array<int,string>
|
||||
*/
|
||||
public static function known_modules(): array {
|
||||
return array_keys( self::all() );
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user