PairScorerCutoffTest.php 3.1 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283
  1. <?php
  2. declare(strict_types=1);
  3. namespace App\Tests\Integration\Reputation;
  4. use App\Domain\Ip\IpAddress;
  5. use App\Domain\Reputation\PairScorer;
  6. use App\Domain\Time\FixedClock;
  7. use App\Infrastructure\Category\CategoryRepository;
  8. use App\Infrastructure\Reputation\ReportRepository;
  9. use App\Tests\Integration\Support\AppTestCase;
  10. use Doctrine\DBAL\ParameterType;
  11. /**
  12. * The hard cutoff (default 365 days) lives in PairScorer, not in the Decay
  13. * function. A report older than the cutoff must be entirely excluded from
  14. * the score even if its decay weight would still be non-zero.
  15. *
  16. * For exponential decay, that's the only way the score reaches *exactly*
  17. * zero (the formula `0.5^(age/half_life)` is strictly positive).
  18. */
  19. final class PairScorerCutoffTest extends AppTestCase
  20. {
  21. public function testReportBeyondHardCutoffExcludedEntirely(): void
  22. {
  23. $clock = FixedClock::at('2026-04-29T00:00:00Z');
  24. $reporterId = $this->createReporter('cutoff-test');
  25. $ip = IpAddress::fromString('203.0.113.99');
  26. /** @var int $catId */
  27. $catId = (int) $this->db->fetchOne("SELECT id FROM categories WHERE slug='brute_force'");
  28. // 400 days ago — beyond the 365-day default cutoff.
  29. $this->db->insert('reports', [
  30. 'ip_bin' => $ip->binary(),
  31. 'ip_text' => $ip->text(),
  32. 'category_id' => $catId,
  33. 'reporter_id' => $reporterId,
  34. 'weight_at_report' => '1.00',
  35. 'received_at' => $clock->now()->modify('-400 days')->format('Y-m-d H:i:s'),
  36. ], ['ip_bin' => ParameterType::LARGE_OBJECT]);
  37. $scorer = new PairScorer(
  38. new ReportRepository($this->db),
  39. new CategoryRepository($this->db),
  40. $clock,
  41. 365,
  42. );
  43. self::assertSame(0.0, $scorer->score($ip->binary(), $catId, $clock->now()));
  44. }
  45. public function testReportInsideCutoffIsCounted(): void
  46. {
  47. $clock = FixedClock::at('2026-04-29T00:00:00Z');
  48. $reporterId = $this->createReporter('inside-cutoff-test');
  49. $ip = IpAddress::fromString('203.0.113.123');
  50. /** @var int $catId */
  51. $catId = (int) $this->db->fetchOne("SELECT id FROM categories WHERE slug='brute_force'");
  52. // 364 days ago — barely inside the cutoff. With a 14-day half-life,
  53. // its weight is essentially zero (0.5^26 ≈ 1.5e-8) but still > 0.
  54. $this->db->insert('reports', [
  55. 'ip_bin' => $ip->binary(),
  56. 'ip_text' => $ip->text(),
  57. 'category_id' => $catId,
  58. 'reporter_id' => $reporterId,
  59. 'weight_at_report' => '1.00',
  60. 'received_at' => $clock->now()->modify('-364 days')->format('Y-m-d H:i:s'),
  61. ], ['ip_bin' => ParameterType::LARGE_OBJECT]);
  62. $scorer = new PairScorer(
  63. new ReportRepository($this->db),
  64. new CategoryRepository($this->db),
  65. $clock,
  66. 365,
  67. );
  68. $score = $scorer->score($ip->binary(), $catId, $clock->now());
  69. self::assertGreaterThan(0.0, $score);
  70. self::assertLessThan(1e-6, $score);
  71. }
  72. }