['7', 2, 7], 'admin_ids' => ['9', 3]]) === ['role_ids' => [2, 7], 'admin_ids' => [3, 9]], 'IDs normalize, deduplicate and sort'); $expect(Visibility::normalizePolicy(['role_ids' => [], 'admin_ids' => []]) === ['role_ids' => [], 'admin_ids' => []], 'empty policy stays empty'); foreach ([[], ['role_ids' => []], ['role_ids' => [], 'admin_ids' => [], 'root' => true], ['role_ids' => '*', 'admin_ids' => []], ['role_ids' => [true], 'admin_ids' => []], ['role_ids' => [1.0], 'admin_ids' => []], ['role_ids' => [-1], 'admin_ids' => []], ['role_ids' => ['01'], 'admin_ids' => []], ['role_ids' => ['1,2'], 'admin_ids' => []], ['role_ids' => ['id' => 1], 'admin_ids' => []], ['role_ids' => [PHP_INT_MAX], 'admin_ids' => []], ['role_ids' => array_fill(0, Visibility::MAX_ROLES + 1, 1), 'admin_ids' => []]] as $invalid) { try { Visibility::normalizePolicy($invalid); $expect(false, 'invalid policy must fail'); } catch (DomainException $e) { $checks++; } } $sentinel = 'private-agreement-sentinel'; $body = ['report' => ['summary' => 'clinical report', 'comparison' => ['reason' => $sentinel], 'raw' => [$sentinel], 'unknown_debug' => ['normalization' => $sentinel]], 'candidate' => ['status' => 'available_for_review', 'reason' => 'clinical reasoning', 'herbs' => [['name' => '测试药材']]], 'comparison' => ['score' => 88, 'normalization' => ['matched_contribution_sum' => 2]], 'coverage' => ['status' => 'complete'], 'usage' => ['total_calls' => 2], 'knowledge_source' => ['title' => 'clinical reference'], 'ancient_book_sources' => [['title' => 'book']], 'score' => $sentinel, 'result' => [$sentinel], 'raw' => [$sentinel], 'body' => [$sentinel], 'progress' => ['stage' => 'poisoned']]; $public = Visibility::reportBody($body, false); $expect(!str_contains(json_encode($public), $sentinel) && !isset($public['comparison']), 'comparison and unknown raw body paths cannot escape'); $expect($public['report']['summary'] === 'clinical report' && $public['candidate']['reason'] === 'clinical reasoning' && $public['coverage']['status'] === 'complete', 'report, candidate reasoning and coverage survive'); $private = Visibility::reportBody($body, true); $expect($private['comparison']['score'] === 88 && !isset($private['result']) && !isset($private['progress']), 'authorized comparison survives but arbitrary body cannot override response fields'); $expect(!isset($private['report']['unknown_debug']) && !isset($private['report']['raw']), 'known clinical report fields are projected at every depth for all viewers'); $batch = ['comparison_type' => $sentinel, 'baseline_eligible' => true, 'baseline_exclusion_reasons' => [$sentinel], 'models' => ['qwen' => ['status' => 'success', 'score' => 88, 'herb_score' => 90, 'reason' => $sentinel, 'comparison_status' => 'comparable', 'comparison_reason_code' => $sentinel, 'algorithm_version' => $sentinel, 'dictionary_version' => $sentinel, 'error_code' => '', 'comparison' => ['rows' => [$sentinel]], 'progress' => ['stage' => 'completed']] + $public, 'openai' => ['status' => 'failed', 'error_code' => 'UPSTREAM_TIMEOUT', 'reason' => '模型请求超时']]]; $hidden = Visibility::projectBatch($batch, false); $expect($hidden['agreement_visible'] === false && !str_contains(json_encode($hidden), $sentinel), 'whole batch removes all metric aliases and derived fields'); $expect($hidden['models']['openai']['reason'] === '模型请求超时' && $hidden['models']['qwen']['progress']['stage'] === 'completed', 'failure explanation and trusted progress remain'); $expect(Visibility::projectBatch($batch, true)['models']['qwen']['score'] === 88, 'authorized stored metric is unchanged'); $expect(Visibility::projectBatch(['models' => []], false) === ['models' => [], 'agreement_visible' => false], 'empty batch advertises capability'); echo "Prescription AI visibility: {$checks} offline checks passed\n";