Files
zyt/server/tests/PrescriptionAiComparisonTest.php
T
2026-09-10 15:19:17 +08:00

225 lines
16 KiB
PHP

<?php
declare(strict_types=1);
use app\common\service\prescriptionai\PrescriptionAiComparison;
require dirname(__DIR__) . '/app/common/service/prescriptionai/PrescriptionAiComparison.php';
// Standalone fixtures only: no framework bootstrap, environment, database or HTTP.
$checks = 0;
function comparisonExpect(bool $condition, string $message): void
{
global $checks;
$checks++;
if (!$condition) {
throw new RuntimeException($message);
}
}
function comparisonNear($actual, float $expected, string $message): void
{
comparisonExpect(is_numeric($actual) && abs((float) $actual - $expected) < 1.0e-10, $message);
}
function comparisonBlocked(array $result, string $code): void
{
comparisonExpect($result['status'] === 'not_comparable' && $result['score'] === null, 'Invalid input must not produce a score: ' . $code);
comparisonExpect(in_array($code, array_column($result['normalization']['issues'], 'code'), true), 'Missing reason: ' . $code);
json_encode($result, JSON_THROW_ON_ERROR);
}
$catalog = [
['id' => 1, 'name' => '黄芪', 'aliases' => ['黄耆'], 'dictionary_version' => 'fixture-v1'],
['id' => 2, 'name' => '党参', 'dictionary_version' => 'fixture-v1'],
['id' => 3, 'name' => '白术', 'dictionary_version' => 'fixture-v1'],
['id' => 4, 'name' => '茯苓', 'dictionary_version' => 'fixture-v1'],
['id' => 5, 'name' => '炙甘草', 'processing' => '蜜炙', 'dictionary_version' => 'fixture-v1'],
];
$herb = static fn (string $name, $dosage, array $extra = []): array => array_replace([
'name' => $name, 'dosage' => $dosage, 'unit' => 'g', 'dose_basis' => 'per_dose', 'formula_type' => '主方',
], $extra);
$rx = static fn (array $herbs, array $extra = []): array => array_replace([
'prescription_type' => '饮片', 'herbs' => $herbs,
'dose_count' => 7, 'usage_days' => 7, 'times_per_day' => 2, 'usage_way' => '温服',
], $extra);
$run = static fn (array $doctor, array $candidate, ?array $dictionary = null): array => PrescriptionAiComparison::compare($doctor, $candidate, $dictionary ?? $catalog);
$doctor = $rx([$herb('黄芪', 12), $herb('党参', 10)]);
$same = $run($doctor, $doctor);
comparisonExpect(array_keys($same) === ['status', 'score', 'herb_score', 'reason_code', 'reason', 'algorithm_version', 'doctor_count', 'candidate_count', 'matched_count', 'rows', 'usage_differences', 'normalization'], 'Public response shape is stable');
comparisonNear($same['score'], 100.0, 'Identical valid prescriptions have score 100');
comparisonNear($same['herb_score'], 100.0, 'Identical herb overlap has score 100');
comparisonExpect($same['doctor_count'] === 2 && $same['matched_count'] === 2, 'Counts use normalized medication items');
comparisonExpect($same['normalization']['denominator'] === 4 && $same['usage_differences'] === [], 'Denominator and equal usage are transparent');
comparisonExpect($same['normalization']['dictionary_versions'] === ['fixture-v1'], 'Dictionary versions are retained');
$legacy = $rx([['name' => '黄芪', 'medicine_id' => 1, 'dosage' => '12.00'], ['name' => '党参', 'dosage' => 10]]);
$legacyResult = $run($legacy, $doctor);
comparisonNear($legacyResult['score'], 100.0, 'Persisted doctor 饮片 contract supplies absent g/per_dose/main only');
comparisonExpect(count($legacyResult['normalization']['doctor']['defaults']) === 6, 'Every persisted-contract default is audited');
comparisonBlocked($run($doctor, $legacy), 'ambiguous_herb_role');
$disjoint = $run($doctor, $rx([$herb('白术', 12), $herb('茯苓', 10)]));
comparisonNear($disjoint['score'], 0.0, 'Nonempty comparable disjoint prescriptions are genuine zero');
comparisonNear($disjoint['herb_score'], 0.0, 'Disjoint herb overlap is zero');
comparisonExpect($disjoint['matched_count'] === 0, 'Disjoint match count remains zero');
$part = $run($doctor, $rx([$herb('黄芪', 6), $herb('白术', 10)]));
comparisonNear($part['score'], 25.0, 'Half dosage contribution plus one unmatched item yields 25');
comparisonNear($part['herb_score'], 50.0, 'Herb overlap is independent of dosage weighting');
comparisonExpect(count(array_filter($part['rows'], static fn (array $row): bool => $row['match_type'] === 'matched')) === 1, 'Detail distinguishes matched and one-sided herbs');
$tenCatalog = [];
$tenDoctor = [];
$tenCandidate = [];
for ($index = 1; $index <= 12; $index++) {
$tenCatalog[] = ['id' => $index, 'name' => '测试药' . $index];
if ($index <= 10) {
$tenDoctor[] = $herb('测试药' . $index, 10);
}
if ($index <= 8 || $index >= 11) {
$tenCandidate[] = $herb('测试药' . $index, 10);
}
}
comparisonNear($run($rx($tenDoctor), $rx($tenCandidate), $tenCatalog)['score'], 80.0, 'Plan example: eight common among ten each is 80');
$tenCandidate[0]['dosage'] = 5;
comparisonNear($run($rx($tenDoctor), $rx($tenCandidate), $tenCatalog)['score'], 75.0, 'Plan example: one half-dose common item yields 75');
$forged = $run($rx([$herb('黄芪', 10)]), $rx([$herb('白术', 10, ['medicine_id' => 1])]));
comparisonNear($forged['score'], 0.0, 'Model cannot forge an overlapping identity using medicine_id');
$alias = $run($rx([$herb('黄芪', 10)]), $rx([$herb(' 黄耆 ', 10, ['medicine_id' => 987])]));
comparisonNear($alias['score'], 100.0, 'Trusted unique alias maps by name regardless of invented model ID');
comparisonExpect($alias['rows'][0]['candidate']['medicine_id'] === 1, 'Output identity comes only from the dictionary');
comparisonBlocked($run($rx([$herb('黄芪', 10, ['medicine_id' => 3])]), $rx([$herb('黄芪', 10)])), 'doctor_identity_mismatch');
comparisonBlocked($run($doctor, $rx([$herb('不认识的药', 10, ['medicine_id' => 1])])), 'unknown_herb_name');
$unknownPartial = $run($doctor, $rx([$herb('黄芪', 12), $herb('不认识的药', 10)]));
comparisonBlocked($unknownPartial, 'unknown_herb_name');
comparisonExpect($unknownPartial['herb_score'] === null, 'Unknown herbs must not be dropped to fabricate even a complete herb score');
comparisonBlocked($run($doctor, $doctor, []), 'catalog_unavailable');
$ambiguousCatalog = array_merge($catalog, [['id' => 8, 'name' => '其他药', 'aliases' => ['黄芪']]]);
comparisonBlocked($run($doctor, $doctor, $ambiguousCatalog), 'ambiguous_herb_name');
$corruptCatalog = array_merge($catalog, [['id' => 1, 'name' => '伪同ID药']]);
comparisonBlocked($run($doctor, $rx([$herb('伪同ID药', 12)]), $corruptCatalog), 'ambiguous_herb_name');
comparisonExpect($same['normalization']['dictionary_hash'] === $run($doctor, $doctor, array_reverse($catalog))['normalization']['dictionary_hash'], 'Dictionary hash ignores server row ordering');
$single = $rx([$herb('黄芪', 10)]);
$split = $rx([$herb('黄芪', 4), $herb('黄耆', 6)]);
$merged = $run($single, $split);
comparisonNear($merged['score'], 100.0, 'Splitting a same-semantics dose cannot manipulate the score');
comparisonExpect($merged['candidate_count'] === 1 && count($merged['normalization']['candidate']['merges']) === 1, 'Merged item count and merge audit are retained');
comparisonExpect($merged['rows'][0]['candidate']['source_rows'] === [0, 1], 'Merge trace points to both original rows');
comparisonNear($run($single, $rx([$herb('黄芪', 10), $herb('黄芪', 10)]))['score'], 50.0, 'Repeated complete doses add, rather than silently deduplicating');
$differentUsage = $rx([$herb('黄芪', 4), $herb('黄芪', 6, ['decoction_instruction' => '先煎'])]);
comparisonBlocked($run($single, $differentUsage), 'duplicate_semantics_conflict');
comparisonBlocked($run($single, $rx([$herb('黄芪', 4), $herb('黄芪', 6, ['unit' => 'mg'])])), 'duplicate_semantics_conflict');
comparisonBlocked($run($single, $rx([$herb('黄芪', 1.0e308), $herb('黄芪', 1.0e308)])), 'invalid_dosage');
comparisonNear($run($single, $rx([$herb('黄芪', 10, ['formula_type' => '辅方'])]))['score'], 0.0, 'Main and auxiliary roles are never rearranged to maximize score');
comparisonNear($run($single, $rx([$herb('黄芪', 10, ['processing' => '蜜炙'])]))['score'], 0.0, 'Distinct processing is a distinct medication item');
comparisonNear($run($single, $rx([$herb('黄芪', 10, ['administration_route' => '外用'])]))['score'], 0.0, 'Distinct route is part of medication identity');
comparisonNear($run($single, $rx([$herb('黄芪', 10, ['group' => '睡前组'])]))['score'], 0.0, 'Explicit grouping is not permuted');
comparisonBlocked($run($single, $rx([$herb('黄芪', 10, ['formula_type' => '备选'])])), 'ambiguous_herb_role');
comparisonBlocked($run($rx([$herb('炙甘草', 10)]), $rx([$herb('炙甘草', 10, ['processing' => '生品'])])), 'processing_conflict');
// Institution catalogs commonly carry the processed form inside the name. Restating it is a label,
// not a second identity; a processing the name does not carry still splits the item.
$namedCatalog = [
['id' => 11, 'name' => '醋五味子', 'dictionary_version' => 'fixture-v1'],
['id' => 12, 'name' => '麸炒白术', 'dictionary_version' => 'fixture-v1'],
['id' => 13, 'name' => '黄芪', 'dictionary_version' => 'fixture-v1'],
];
$named = static fn (array $doctor, array $candidate): array => PrescriptionAiComparison::compare($doctor, $candidate, $namedCatalog);
comparisonNear($named($rx([$herb('醋五味子', 6)]), $rx([$herb('醋五味子', 6, ['processing' => '醋制'])]))['score'], 100.0,
'A processing label already carried by the medicine name does not split the item');
comparisonNear($named($rx([$herb('麸炒白术', 12)]), $rx([$herb('麸炒白术', 12, ['processing' => '麸炒'])]))['score'], 100.0,
'Multi-character processing labels restating the name are also treated as one identity');
$labelled = $named($rx([$herb('醋五味子', 6)]), $rx([$herb('醋五味子', 6, ['processing' => '醋制'])]));
comparisonExpect(in_array('herb_processing_label', array_column($labelled['usage_differences'], 'field'), true),
'The differing processing label is still reported as a difference to review');
comparisonNear($named($rx([$herb('黄芪', 10)]), $rx([$herb('黄芪', 10, ['processing' => '蜜炙'])]))['score'], 0.0,
'A processing the name does not carry remains a distinct medication item');
foreach ([null, '', '未知', 0, false] as $unit) {
comparisonBlocked($run($single, $rx([$herb('黄芪', 10, ['unit' => $unit])])), 'missing_or_unknown_unit');
}
foreach ([null, '', '每次', 'total', false] as $basis) {
comparisonBlocked($run($single, $rx([$herb('黄芪', 10, ['dose_basis' => $basis])])), 'missing_or_unknown_dose_basis');
}
comparisonBlocked($run($single, $rx([$herb('黄芪', 10000, ['unit' => 'mg'])])), 'unit_mismatch');
comparisonNear($run($single, $rx([$herb('黄芪', '10.0', ['unit' => '克', 'dose_basis' => '每剂'])]))['score'], 100.0, 'Only unit and basis spelling aliases normalize');
comparisonBlocked($run($single, $rx([$herb('黄芪', 10)], ['prescription_type' => '颗粒'])), 'formulation_mismatch');
comparisonBlocked($run($single, $rx([$herb('白术', 10, ['dose_basis' => 'per_day'])])), 'dose_basis_mismatch');
comparisonBlocked($run($rx([['name' => '黄芪', 'dosage' => 10]], ['prescription_type' => '浓缩水丸']), $rx([$herb('黄芪', 10)], ['prescription_type' => '浓缩水丸'])), 'missing_or_unknown_unit');
comparisonBlocked($run($rx([$herb('黄芪', 10, ['unit' => null])]), $single), 'missing_or_unknown_unit');
foreach ([null, '', ' ', 0, -1, '0', '-0.1', true, false, [], '十', '10g', 'NaN', 'INF', INF, -INF, NAN, '1e9999', '1e-9999'] as $dose) {
$invalidDose = $run($single, $rx([$herb('黄芪', $dose)]));
comparisonBlocked($invalidDose, 'invalid_dosage');
comparisonNear($invalidDose['herb_score'], 100.0, 'Known herb overlap may survive invalid dosage, but never replace S');
comparisonExpect($invalidDose['rows'][0]['contribution'] === null, 'Invalid full comparison cannot show usable partial contributions');
}
comparisonBlocked($run($rx([]), $rx([])), 'empty_prescription');
comparisonBlocked($run($single, $rx([])), 'empty_prescription');
comparisonBlocked($run($single, $rx([null])), 'invalid_herb');
foreach (['insufficient_data', 'withheld_for_risk', 'failed', 'no_medication'] as $status) {
comparisonBlocked($run($single, $rx([], ['status' => $status])), $status);
}
$usage = $run($single, $rx([$herb('黄芪', 10, ['decoction_instruction' => '后下'])], [
'dose_count' => 14, 'usage_days' => 14, 'times_per_day' => 3, 'usage_way' => '冷服',
'aux_usage' => ['usage_days' => 3],
]));
comparisonNear($usage['score'], 100.0, 'Usage changes remain visible even at 100 structural agreement');
comparisonExpect(count($usage['usage_differences']) === 6, 'Course, frequency, route, auxiliary plan and herb instruction differences are retained');
$sameUsage = $run($single, $rx([$herb('黄芪', 10)], ['dose_count' => '7', 'usage_days' => '7.0', 'times_per_day' => '2']));
comparisonExpect($sameUsage['usage_differences'] === [], 'Numeric database serialization does not invent usage changes');
// Real workstation rows: the per-herb unit lives once on the prescription (用量单位) and the
// dose basis is declared by 剂量单位=剂. Neither may be guessed when the prescription omits them.
$storedHerb = static fn (string $name, $dosage): array => ['name' => $name, 'dosage' => $dosage, 'formula_type' => '主方'];
$storedRx = static fn (array $herbs, array $extra = []): array => array_replace([
'prescription_type' => '浓缩水丸', 'dosage_unit' => 'g', 'dose_unit' => '剂', 'dose_count' => 1,
'usage_days' => 7, 'times_per_day' => 2, 'herbs' => $herbs,
], $extra);
$aiRx = static fn (array $herbs): array => [
'status' => 'available_for_review', 'prescription_type' => '浓缩水丸', 'dose_basis' => 'per_dose',
'herbs' => $herbs, 'usage_days' => 7, 'times_per_day' => 2, 'dose_count' => 1,
];
$stored = $run($storedRx([$storedHerb('黄芪', '16'), $storedHerb('党参', '15')]),
$aiRx([$herb('黄芪', 16.0), $herb('党参', 15.0)]));
comparisonNear($stored['score'], 100.0, 'Stored rows without a per-row unit compare through the prescription 用量单位 and 剂量单位');
comparisonExpect(count($stored['normalization']['doctor']['defaults']) === 4,
'Every applied unit and dose-basis fallback stays recorded per row');
$halfDose = $run($storedRx([$storedHerb('黄芪', '16'), $storedHerb('党参', '15')]),
$aiRx([$herb('黄芪', 8.0), $herb('党参', 15.0)]));
comparisonNear($halfDose['score'], 75.0, 'A doubled dose in one common herb halves that row contribution');
comparisonBlocked($run($storedRx([$storedHerb('黄芪', '16')], ['dosage_unit' => '']),
$aiRx([$herb('黄芪', 16.0)])), 'missing_or_unknown_unit');
comparisonBlocked($run($storedRx([$storedHerb('黄芪', '16')], ['dose_unit' => '盒', 'prescription_type' => '浓缩水丸']),
$aiRx([$herb('黄芪', 16.0)])), 'missing_or_unknown_dose_basis');
$mlRx = $run($storedRx([$storedHerb('黄芪', '16')], ['dosage_unit' => 'ml']), $aiRx([$herb('黄芪', 16.0)]));
comparisonExpect($mlRx['status'] === 'not_comparable' && $mlRx['normalization']['issues'][0]['code'] === 'unit_mismatch',
'A declared millilitre prescription is never silently compared against grams');
$unitNoise = $run($storedRx([$storedHerb('黄芪', '16')]), $aiRx([$herb('黄芪', 16.0)]));
comparisonExpect(!in_array('dosage_unit', array_column($unitNoise['usage_differences'], 'field'), true),
'A unanimous per-herb unit is not reported as a usage difference against the prescription 用量单位');
$mixedUnits = $run($storedRx([$storedHerb('黄芪', '16')]),
$aiRx([$herb('黄芪', 16.0), $herb('党参', 10.0, ['unit' => 'ml'])]));
comparisonExpect(in_array('dosage_unit', array_column($mixedUnits['usage_differences'], 'field'), true),
'Mixed candidate units are never presented as one agreed prescription unit');
// Deterministic algebra properties across nontrivial overlaps and dose ratios.
for ($iteration = 1; $iteration <= 25; $iteration++) {
$left = $rx([$herb('黄芪', $iteration * 0.7), $herb('党参', 9.0)]);
$right = $rx([$herb('黄芪', ($iteration + 3) * 0.4), $herb('白术', 11.0)]);
$forward = $run($left, $right);
$reverse = $run($right, $left);
comparisonNear($forward['score'], $reverse['score'], 'Soft-Dice is symmetric');
comparisonExpect($forward['score'] >= 0.0 && $forward['score'] <= $forward['herb_score'], 'Dose agreement is bounded by herb agreement');
$right['herbs'] = array_reverse($right['herbs']);
comparisonNear($forward['score'], $run($left, $right)['score'], 'Row permutation cannot change the score');
}
echo 'PRESCRIPTION_AI_COMPARISON_TEST_OK ' . $checks . " checks\n";