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 */ 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(), ); } }