knowledge = $knowledge ?? new DomainKnowledge(); } public function classify(DomainSignals $signals): DomainCapabilities { $evidenceByDomain = []; foreach ($this->knowledge->getRules()->getAll() as $rule) { $matchedValue = $this->match($rule, $signals); if ($matchedValue === null) { continue; } $evidence = new DomainEvidence( $rule['type'], $matchedValue, $rule['domain'], $rule['weight'], $rule['reason'] ); $evidenceByDomain[$rule['domain']][] = $evidence; } if ($evidenceByDomain === []) { $domain = $signals->getInput() === '' ? DomainRegistry::UNKNOWN : DomainRegistry::GENERIC_PRODUCTS; $confidence = $domain === DomainRegistry::UNKNOWN ? 0.0 : 0.35; $reason = $domain === DomainRegistry::UNKNOWN ? 'Empty input produced no domain evidence.' : 'No knowledge rule matched; generic product fallback selected.'; return new DomainCapabilities($signals->getIdentifierType(), $domain, [], $confidence, [$reason]); } $scores = []; foreach ($evidenceByDomain as $domain => $domainEvidence) { $scores[$domain] = min(1.0, array_sum(array_map( static fn (DomainEvidence $evidence): float => $evidence->getWeight(), $domainEvidence ))); } uksort($scores, static function (string $left, string $right) use ($scores): int { $scoreComparison = $scores[$right] <=> $scores[$left]; return $scoreComparison !== 0 ? $scoreComparison : strcasecmp($left, $right); }); $domain = (string) array_key_first($scores); $alternatives = []; foreach ($scores as $candidateDomain => $score) { if ($candidateDomain === $domain) { continue; } $alternatives[] = [ 'domain' => $candidateDomain, 'confidence' => round($score, 2), 'evidence_count' => count($evidenceByDomain[$candidateDomain]), ]; } $reasoning = array_map( static fn (DomainEvidence $evidence): string => $evidence->getReason(), $evidenceByDomain[$domain] ); $reasoning[] = 'Weighted evidence selected the highest-scoring domain.'; return new DomainCapabilities( $signals->getIdentifierType(), $domain, [], round($scores[$domain], 2), array_values(array_unique($reasoning)), $evidenceByDomain[$domain], $alternatives ); } private function match(array $rule, DomainSignals $signals): ?string { foreach ($rule['values'] as $value) { $normalizedValue = strtolower(trim((string) $value)); if ($rule['type'] === 'identifier' && $signals->getIdentifierType() === $value) { return (string) $value; } if ($rule['type'] === 'category' && $this->containsAny($signals->getCategoryHints(), $normalizedValue)) { return (string) $value; } if ($rule['type'] === 'brand' && ($this->contains($signals->getBrand(), $normalizedValue) || $this->contains($signals->getInput(), $normalizedValue))) { return (string) $value; } if ($rule['type'] === 'manufacturer' && $this->contains($signals->getManufacturer(), $normalizedValue)) { return (string) $value; } if (in_array($rule['type'], ['keyword', 'pattern'], true) && $this->contains($signals->getInput(), $normalizedValue)) { return (string) $value; } } return null; } private function containsAny(array $haystacks, string $needle): bool { foreach ($haystacks as $haystack) { if ($this->contains((string) $haystack, $needle)) { return true; } } return false; } private function contains(string $haystack, string $needle): bool { return $haystack !== '' && $needle !== '' && strpos($haystack, $needle) !== false; } }