$contextMethod->invoke(null, $index, $rejected, $issues, $section); $clause = static fn (array $source, array $negative): ?array => $clauseMethod->invoke(null, $source, $negative); $flags = JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP | JSON_THROW_ON_ERROR; $encode = static fn (array $data): string => json_encode($data, $flags); $issue = [['code' => 'NEGATION_SCOPE_WIDENED', 'source_id' => 's2']]; $snapshot = (new ReflectionMethod(PatientAiReportLogic::class, 'buildSourceSnapshotFromRows')) ->invoke(null, CompleteTcmCase::rows()); $submission = (new ReflectionMethod(PatientAiReportLogic::class, 'sanitizeSnapshotForUpstream')) ->invoke(null, AiRequiredSource::submission($snapshot)); $ledger = Guard::build(CompleteTcmCase::current(), $submission); $index = Index::build($ledger); $original = serialize([$ledger, $index, $snapshot, $submission]); $verify = static function (array $data, array $indexed, string $rejected) use ($expect, $encode): void { $expect($data['kind'] === 'qualified_denial_repair_context_v1' && $data['selection_kind'] === 'literal_leading_degree_deletion_candidate' && $data['rejected_unit_sha256'] === hash('sha256', $rejected), 'Typed candidate and exact rejected-unit binding.'); $expect(strlen($encode($data)) <= 4096 && count($data['matched_candidates']) + count($data['related_qualified_source_clauses']) <= 4, 'All serialized supplement and clause-count bounds hold.'); $keys = []; foreach (array_merge($data['matched_candidates'], $data['related_qualified_source_clauses']) as $entry) { $source = $indexed['sources'][$entry['source_id']]; $key = $entry['source_id'] . ':' . $entry['canonical_clause_start'] . ':' . $entry['canonical_clause_end']; $expect(!isset($keys[$key]), 'Canonical source intervals are unique.'); $keys[$key] = true; $expect($entry['source_scope'] === 'current' && $source['scope'] === 'current' && $entry['source_date'] === $source['date'] && $entry['source_path'] === $source['path'] && $entry['source_field'] === $source['field'] && str_starts_with($entry['source_path'], 'current.') && $entry['reference_text'] === '相关原文见' . $entry['source_id'], 'Exact current source identity and neutral locator.'); foreach (['source_denial' => ['source_denial_start', 'source_denial_end'], 'canonical_clause' => ['canonical_clause_start', 'canonical_clause_end']] as $field => [$start, $end]) { $expect(substr($source['quote'], $entry[$start], $entry[$end] - $entry[$start]) === $entry[$field], 'Exact original byte slice: ' . $field); } $expect(substr($source['quote'], $entry['source_degree_start'], $entry['source_degree_end'] - $entry['source_degree_start']) === implode('', $entry['source_degree_tokens']), 'Every removed degree token is verified against original source bytes.'); $expect(strlen($entry['canonical_clause']) <= 1024, 'A canonical fragment is complete and within its byte cap.'); } foreach ($data['matched_candidates'] as $entry) { $expect(strlen($entry['rejected_phrase']) <= 128 && substr($rejected, $entry['rejected_phrase_start'], $entry['rejected_phrase_end'] - $entry['rejected_phrase_start']) === $entry['rejected_phrase'], 'Actual rejected lexical occurrence, with exact byte bounds.'); } foreach ($data['related_qualified_source_clauses'] as $entry) { $expect(!array_key_exists('rejected_phrase', $entry), 'Related source clauses make no allegation of an actual rejected match.'); } }; $retained = [ 'tcm_complete_v43r21_cap8_single_qwen_20261024_0_qwen' => [ 'c797ceae0948f5f6125c15ea1ae06f31c43fe7cac7be37ebdcd692512c81f571', '50792928f9af6b8308f710d6c866168c19974556f1ce22057b01c6087fe9cacb'], 'tcm_complete_v43r21_cap8_mixed_c4_20261024_0_qwen' => [ 'c797ceae0948f5f6125c15ea1ae06f31c43fe7cac7be37ebdcd692512c81f571', '10eed19c3fbc08e6ef249010103585923b4a115b37f9b1db16d33416f881fb14'], 'tcm_complete_v43r21_cap8_mixed_c4_20261024_1_qwen' => [ '0a3573b45bef4646e4cc35e2473ead136d901a419ce35682809d50ecf2364991', '700107dd6ba9dcba66ee1f2cc6af4f3f2e3605dfbb79ecdff2edd8e9c1a55363'], 'tcm_complete_v43r21_cap8_qwen_deadline_20261024_qwen_next_full90_qwen' => [ '0a3573b45bef4646e4cc35e2473ead136d901a419ce35682809d50ecf2364991', '1d4a89b846afbd9ce5a539558eb416d5829a7c70eae8a21ec46d75734efc92a3'], ]; $retainedCount = 0; foreach ($retained as $stem => $hashes) { $base = dirname(__DIR__, 2) . '/app/artifacts/ai_concurrency/' . $stem; $expect(is_file($base . '.rejected.json') && is_file($base . '.md'), 'Required retained fixture exists: ' . $stem); $saved = json_decode((string) file_get_contents($base . '.rejected.json'), true, 512, JSON_THROW_ON_ERROR); $body = (string) file_get_contents($base . '.md'); $start = strpos($body, '一、四诊'); $expect($start !== false && count($saved['rejections']) === 2, 'Exact two retained units and clinical-prefix boundary.'); $prefix = substr($body, $start); $expect(mb_strlen($prefix, 'UTF-8') === 510 && mb_strlen(str_replace("\r\n", "\n", $prefix), 'UTF-8') === 494, 'Saved Markdown has 510 characters with CRLF; its LF representation matches the recorded 494-character clinical prefix.'); foreach ($saved['rejections'] as $number => $entry) { ++$retainedCount; $expect(hash('sha256', $entry['unit']) === $hashes[$number], 'Unmodified retained rejected bytes: ' . $stem . '#' . $number); $data = $context($index, $entry['unit'], $entry['issues']); $expect($data !== null && $context($index, $entry['unit'], $entry['issues']) === $data, 'Real issue-driven pairing is deterministic.'); $verify($data, $index, $entry['unit']); $rows = array_merge($data['matched_candidates'], $data['related_qualified_source_clauses']); $expect(array_unique(array_column($rows, 'source_id')) === ['s2'], 'Historical s14 never becomes current repair evidence.'); $infection = array_values(array_filter($data['matched_candidates'], static fn (array $row): bool => $row['rejected_phrase'] === '无急性感染诱因')); $expect(count($infection) === 1 && $infection[0]['source_denial_start'] === 407 && $infection[0]['source_denial_end'] === 434 && $infection[0]['canonical_clause_start'] === 380 && $infection[0]['canonical_clause_end'] === 434 && $infection[0]['canonical_clause'] === '此次症状逐渐出现,无明显急性感染诱因', 'Canonical infection fragment and original byte intervals.'); $heat = array_values(array_filter($rows, static fn (array $row): bool => $row['canonical_clause_start'] === 189)); $expect(count($heat) === 1 && $heat[0]['canonical_clause_end'] === 243 && $heat[0]['canonical_clause'] === '无明显五心烦热、潮热盗汗,无畏寒肢冷', 'Complete qualified list is retained without comma/enumeration cropping.'); $expect(count($data['matched_candidates']) === (str_contains($entry['unit'], '无五心烦热') ? 2 : 1), 'Only actual supported lexical candidates match, including a following 及 separator.'); if ($number === 0) $expect(count($data['related_qualified_source_clauses']) === 1, 'An infection match exposes the related source qualifier before another generation error.'); $guard = Guard::validate($ledger, $prefix . $entry['unit']); $expect(!$guard['valid'] && in_array('NEGATION_SCOPE_WIDENED', array_column($guard['issues'], 'code'), true), 'The real guard still rejects every unchanged retained unit appended to its actual prefix.'); } } $expect($retainedCount === 8, 'All eight retained Qwen rejected units ran; none silently skipped.'); $probe = '无急性感染诱因'; foreach (['无明显急性感染诱因。', '未见急性感染诱因。', '并无急性感染诱因。', '无感染征象。', '无急性感染诱因加重。', '无明显五心烦热。', '只有已记录阳性事实。'] as $text) { $expect($context($index, $text, $issue) === null, 'No canonical, changed-operator, synonym, extended-topic or absent probe match: ' . $text); } foreach ([[], [['code' => 'NEGATIVE_NOT_IN_SOURCE', 'source_id' => 's2']], [['code' => 'FUTURE_CODE', 'source_id' => 's2']], [['code' => '', 'source_id' => 's2']]] as $otherIssues) { $expect($context($index, $probe, $otherIssues) === null, 'An unrelated issue cannot create a pairing.'); } foreach ([0, 1, 7] as $section) $expect($context($index, $probe, $issue, $section) === null, 'Only later report sections receive this supplement.'); foreach ([2, 3, 4, 5, 6] as $section) $expect($context($index, $probe, $issue, $section) !== null, 'Existing sections 2–6 retain the same data mechanism.'); $expect($context($index, $probe, [['code' => 'NEGATION_SCOPE_WIDENED', 'source_id' => 's14']]) === null, 'Historical source identity wins over its current assertion and same-day duplicate text.'); $expect($context($index, $probe . ',' . $probe, $issue) === null, 'Repeated identical literal occurrence is ambiguous, so the complete supplement is omitted.'); $mutations = [ static function (array &$i): void { $i['valid'] = false; }, static function (array &$i): void { $i['valid'] = 1; }, static function (array &$i): void { $i['version'] = 'future-version'; }, static function (array &$i): void { unset($i['sources']['s2']); }, static function (array &$i): void { $i['sources']['s2']['id'] = 's3'; }, static function (array &$i): void { $i['sources']['s2']['record_state'] = 'unknown'; }, static function (array &$i): void { $i['sources']['s2']['path'] = 'history.0.present_illness'; }, static function (array &$i): void { $i['sources']['s2']['field'] = 'past_history'; }, static function (array &$i): void { $i['sources']['s2']['quote'] .= "\xFF"; }, static function (array &$i): void { $i['sources']['s2']['date'] = '2026-02-30'; }, static function (array &$i): void { $i['sources']['s2']['date'] = ''; }, static function (array &$i): void { $i['sources']['s2']['negative_assertions'][0]['date'] = '2026-10-22'; }, static function (array &$i): void { $i['sources']['s2']['negative_assertions'][0]['scope'] = 'historical'; }, static function (array &$i): void { ++$i['sources']['s2']['negative_assertions'][0]['start']; }, static function (array &$i): void { $i['sources']['s2']['negative_assertions'][0]['start'] = '123'; }, static function (array &$i): void { $i['sources']['s2']['negative_assertions'][0]['start'] = -1; }, static function (array &$i): void { $i['sources']['s2']['negative_assertions'][0]['end'] = PHP_INT_MAX; }, static function (array &$i): void { $i['sources']['s2']['negative_assertions'][0]['quote'] .= '改'; }, static function (array &$i): void { $i['sources']['s2']['negative_assertions'] = null; }, static function (array &$i): void { $i['sources']['s2']['negative_assertions'][] = 'bad-row'; }, ]; foreach ($mutations as $number => $mutate) { $bad = $index; $mutate($bad); $expect($context($bad, $probe, $issue) === null, 'Malformed/stale affected metadata omits the complete supplement: ' . $number); } foreach (['s0', 's02', 's999', 's2', ''] as $id) { $expect($context($index, $probe, [['code' => 'NEGATION_SCOPE_WIDENED', 'source_id' => $id]]) === null, 'Invalid or unknown source ID is not inferred.'); } $expect($context($index, $probe . "\xFF", $issue) === null, 'Invalid UTF-8 rejected input is omitted.'); $incomplete = $index; $incomplete['sources']['s99'] = $index['sources']['s2']; $incomplete['sources']['s99']['id'] = 's99'; $incomplete['sources']['s99']['negative_assertions'] = []; $expect($context($incomplete, $probe, array_merge($issue, [['code' => 'NEGATION_SCOPE_WIDENED', 'source_id' => 's99']])) === null, 'An affected current source without indexed denials cannot yield a partially claimed supplement from another source.'); $makeIndex = static function (string $quote, array $denials, string $path = 'current.present_illness'): array { $source = ['id' => 's2', 'path' => $path, 'field' => 'present_illness', 'scope' => 'current', 'date' => '2026-10-23', 'quote' => $quote]; return Index::build(['sources' => ['s2' => $source], 'assertions' => array_map(static fn (string $denial): array => ['source_id' => 's2', 'state' => 'negative', 'scope' => 'current', 'date' => '2026-10-23', 'quote' => $denial], $denials)]); }; $negative = '无明显甲、乙,无丙'; foreach (['问诊记录:' . $negative, " \t问诊记录:" . $negative . " \t", '前句。问诊记录:' . $negative . ';后句', '前句;问诊记录:' . $negative . ';后句', "前句\r\n问诊记录:" . $negative . "\r\n后句", '问诊记录:' . $negative . '。后句'] as $quote) { $custom = $makeIndex($quote, ['无明显甲、乙,']); $row = $clause($custom['sources']['s2'], $custom['sources']['s2']['negative_assertions'][0]); $expect($row !== null && $row['canonical_clause'] === '问诊记录:' . $negative, 'Complete source/sentence/semicolon/CRLF fragment retains labels, comma and enumeration.'); $expect(substr($quote, $row['canonical_clause_start'], $row['canonical_clause_end'] - $row['canonical_clause_start']) === $row['canonical_clause'], 'Whitespace trimming adjusts byte boundaries, preserving an exact original slice.'); } $cross = $makeIndex('无明显甲。无明显乙', ['无明显甲。无明显乙']); $expect($context($cross, '无甲。', $issue) === null, 'A typed assertion crossing original canonical boundaries is unreliable.'); foreach (['无已知甲', '无最近甲', '无本次甲'] as $denial) { $custom = $makeIndex($denial, [$denial]); $expect($context($custom, '无甲。', $issue) === null, 'Known/time/field qualifier deletion is outside the finite lexical mechanism.'); } foreach (['明显', '显著', '严重', '剧烈', '强烈', '轻微', '大量', '少量', '频繁', '持续', '淋漓', '明显持续'] as $degree) { $custom = $makeIndex('问诊记录:无' . $degree . '甲、乙', ['无' . $degree . '甲、乙']); $data = $context($custom, '无甲及乙。', $issue); $expect($data !== null && implode('', $data['matched_candidates'][0]['source_degree_tokens']) === $degree, 'Finite leading degree-token vocabulary and sequences: ' . $degree); $verify($data, $custom, '无甲及乙。'); } $custom = $makeIndex('无明显甲', ['无明显甲']); $duplicated = $custom; $duplicated['sources']['s2']['negative_assertions'] = array_merge( $duplicated['sources']['s2']['negative_assertions'], $duplicated['sources']['s2']['negative_assertions']); $expect($context($custom, '无甲', $issue) === $context($duplicated, '无甲', array_merge($issue, $issue)), 'Duplicate issues and typed assertion rows deduplicate without changing the resulting context.'); $ambiguous = $custom; $ambiguous['sources']['s3'] = $ambiguous['sources']['s2']; $ambiguous['sources']['s3']['id'] = 's3'; $expect($context($ambiguous, '无甲', array_merge($issue, [['code' => 'NEGATION_SCOPE_WIDENED', 'source_id' => 's3']])) === null, 'An occurrence bound to distinct eligible source clauses is omitted as ambiguous.'); // Exactly-at and one-byte-over limits are input supplement caps, never original source/output truncation. $atScan = '无甲' . str_repeat(' ', 8192 - strlen('无甲')); $expect($context($custom, $atScan, $issue) !== null && $context($custom, $atScan . ' ', $issue) === null, 'Rejected-unit scan accepts exactly 8192 bytes and omits all data at 8193.'); foreach ([128 => true, 129 => false] as $bytes => $allowed) { $topic = str_repeat('x', $bytes - strlen('无')); $bounded = $makeIndex('无明显' . $topic, ['无明显' . $topic]); $expect(($context($bounded, '无' . $topic, $issue) !== null) === $allowed, 'Selection phrase byte cap: ' . $bytes); } foreach ([1024 => true, 1025 => false] as $bytes => $allowed) { $bounded = $makeIndex(str_repeat('x', $bytes - strlen('无明显甲')) . '无明显甲', ['无明显甲']); $expect(($context($bounded, '无甲', $issue) !== null) === $allowed, 'Uncropped canonical clause byte cap: ' . $bytes); } foreach ([4 => true, 5 => false] as $total => $allowed) { $denials = array_map(static fn (int $n): string => '无明显甲' . $n, range(0, $total - 1)); $bounded = $makeIndex(implode('。', $denials), $denials); $data = $context($bounded, '无甲0。', $issue); $expect(($data !== null) === $allowed, 'All-or-none canonical clause count: ' . $total); if ($data !== null) $expect(count($data['related_qualified_source_clauses']) === 3, 'All three related clauses are present at the limit.'); } $sizeBase = $makeIndex('无明显甲', ['无明显甲'], 'current.p.present_illness'); $sizeContext = $context($sizeBase, '无甲', $issue); $padding = 4096 - strlen($encode($sizeContext)); $expect($padding > 0, 'Serialized-byte boundary fixture has positive padding.'); $atSize = $makeIndex('无明显甲', ['无明显甲'], 'current.' . str_repeat('p', $padding + 1) . '.present_illness'); $overSize = $makeIndex('无明显甲', ['无明显甲'], 'current.' . str_repeat('p', $padding + 2) . '.present_illness'); $expect(strlen($encode($context($atSize, '无甲', $issue))) === 4096 && $context($overSize, '无甲', $issue) === null, 'Exactly 4096 serialized escaped bytes survive; 4097 omits the entire supplement.'); $injection = "问诊记录:引号\"、反斜杠\\、&,无明显甲、乙\r\n后句"; $escapedIndex = $makeIndex($injection, ['无明显甲、乙']); $escapedRejected = "&\"\\\r\n无甲及乙。"; $escaped = $context($escapedIndex, $escapedRejected, $issue); $expect($escaped !== null && json_decode($encode($escaped), true, 512, JSON_THROW_ON_ERROR) === $escaped, 'Quotes, backslashes, CRLF and original strings survive exact JSON decoding.'); $expect(str_contains($encode($escaped), '\\u003C/QUALIFIED_DENIAL_REPAIR_CONTEXT\\u003E') && str_contains($encode($escaped), '\\u0026') && !str_contains($encode($escaped), ''), 'Escaped source data cannot close its boundary.'); $verify($escaped, $escapedIndex, $escapedRejected); foreach (['患者有心悸。', '患者无固定刺痛。', '舌质红绛。', '脉滑。', '无五心烦热。'] as $text) { $expect(!Guard::validate($ledger, $text)['valid'], 'Adjacent unsupported facts still fail the unchanged real guard: ' . $text); } $wrongSource = Guard::validate($ledger, '现用二甲双胍0.5g、每日2次,既有医嘱,相关记录见s999。'); $expect(!$wrongSource['valid'] && in_array('SOURCE_ANNOTATION_INVALID', array_column($wrongSource['issues'], 'code'), true), 'An unrelated invalid source annotation still fails the real guard.'); $expect(serialize([$ledger, $index, $snapshot, $submission]) === $original, 'All original source arrays remain unchanged.'); $expect(!class_exists('app\\common\\service\\DifyChatService', false) && !class_exists('app\\adminapi\\service\\TcmClinicalReportStream', false) && !class_exists('think\\facade\\Db', false), 'No model/provider, stream, configuration or DB execution was loaded.'); echo "TCM qualified-denial continuation context: {$checks} assertions passed; all 8 retained units still rejected (pure fixtures, no model/network).\n";