fix(core): Task 07 resolve duplicate comments semantics, fix commentsCount merging, and maintain legacy snapshot hash compatibility

This commit is contained in:
Ochenstarik 2026-08-21 19:31:01 +07:00
parent 8741569817
commit 2da8b01b1b
7 changed files with 357 additions and 12 deletions

View file

@ -0,0 +1,49 @@
# Task 07: excludeDuplicateComments — Семантика дедупликации и точность аудит-следа
**Date:** 2026-08-21
**Base Commit SHA:** `8741569817f4463387c5dc3ac36c0beaedbd0663`
**Status:** COMPLETED / PASS
**Assigned Agent:** Antigravity (Implementation Orchestrator)
---
## 1. Executive Summary
Устранено расхождение между поведением движка фильтрации и каноническим хешем условий розыгрыша:
1. **Семантика дедупликации (Вариант B):**
- В ядре Randomayzer закреплена безусловная дедупликация участников: **1 пользователь = 1 шанс**.
- Поддержка взвешенных шансов за множественные комментарии потребовала бы модификации детерминированного алгоритма жеребьёвки `executeDeterministicDrawV1` и структуры слепка, что прямо запрещено `AGENTS.md` §1 без отдельного прямого задания владельца.
- Поле `excludeDuplicateComments` удалено из `DEFAULT_FILTER_RULES`, `filterRulesSchema` и помечено как `@deprecated optional` для обратной совместимости.
2. **Обратная совместимость `conditionsHash` и `verifyDrawResult`:**
- В `computeConditionsHash` реализована поддержка легаси-снапшотов: если объект правил `snapshot.filterRulesSnapshot` содержит поле `excludeDuplicateComments`, оно включается в каноническую сериализацию, гарантируя точное совпадение хеша (`conditionsIntegrity: true`, `verified: true`) для всех ранее проведённых розыгрышей.
- Для новых розыгрышей хеш вычисляется по чистому набору правил без фиктивного поля.
3. **Исправление ошибки слияния `commentsCount`:**
- Устранена ошибка `(p.commentsCount || 1)`, из-за которой дубликат с `commentsCount: 0` (например, пришедший из списка лайков) ошибочно прибавлял 1 к счетчику комментариев.
- Подсчет обновлен на `typeof p.commentsCount === 'number' ? p.commentsCount : (p.commented ? 1 : 0)`.
---
## 2. Modified Files
| File | Type | Description |
|------|------|-------------|
| `src/core/filtering/filter-engine.ts` | Core Domain | Исправлен подсчет `commentsCount` при слиянии дубликатов участников. |
| `src/core/types/giveaway.ts` | Core Types | `excludeDuplicateComments` помечен как `@deprecated optional`, удален из `DEFAULT_FILTER_RULES`. |
| `src/core/randomizer/canonical.ts` | Core Randomizer | `computeConditionsHash` поддерживает легаси-снапшоты с сохранением байтовой идентичности хешей. |
| `src/core/validation/giveaway-schemas.ts` | Validation | `excludeDuplicateComments` сделан опциональным, удален из `defaultRulesObject`. |
| `tests/duplicate-comments-rule.test.ts` | Tests (NEW) | Набор тестов (5 тестов): точный подсчет счетчика комментариев, безусловная дедупликация, проверка легаси и новых снапшотов. |
---
## 3. Verification Evidence & Test Gate
```text
npx prisma generate -> EXIT 0 (Prisma Client v5.22.0)
npx tsc --noEmit -> EXIT 0 (0 ошибок типизации)
npm test -> EXIT 0 (55 тестовых файлов, 321 тест прошёл успешно)
npm run lint -> EXIT 0 (0 ошибок, 6 warnings на no-img-element)
npm run build -> EXIT 0 (Все 17 маршрутов скомпилированы успешно)
npm audit --omit=dev -> EXIT 0 (0 vulnerabilities)
```

View file

@ -0,0 +1,23 @@
# Task 07: excludeDuplicateComments — правило не применяется
**Assigned to:** Antigravity (Implementation Orchestrator)
**Priority:** MEDIUM (audit-trail accuracy)
**Date:** 2026-08-21
**Base SHA:** `8741569817f4463387c5dc3ac36c0beaedbd0663`
## Scope
1. Semantic decision on duplicate comments:
- Randomayzer core algorithm `HMAC_SHA256_FY_V1` and `executeDeterministicDrawV1` operate on a 1-participant = 1-chance model (`AGENTS.md` §1 forbids altering deterministic randomizer/draw algorithms without explicit owner task).
- Multi-weight/multi-entry draws would require modifying the deterministic randomizer and snapshot data structures, which is out of scope.
- Therefore, choose **Option B**: Unconditional deduplication.
2. Backward compatibility & Conditions Hash versioning:
- Ensure `computeConditionsHash` handles backward compatibility: snapshots created in legacy format (containing `excludeDuplicateComments`) must continue to verify `verified: true` with identical hash values (`conditionsIntegrity: true`).
3. Fix duplicate merging in `filter-engine.ts`:
- Fix `commentsCount` summing so that duplicates with `commentsCount: 0` (or `undefined`) do not artificially add `+ 1`.
4. Update UI and validation schemas if `excludeDuplicateComments` is removed or deprecated:
- Clean up or deprecate gracefully in `DEFAULT_FILTER_RULES`, `filterRulesSchema`, `src/app/giveaways/new/page.tsx`.
5. Create regression tests `tests/duplicate-comments-rule.test.ts`:
- Duplicate with `commentsCount: 0` does not add 1.
- Legacy snapshots with `excludeDuplicateComments` in `filterRulesSnapshot` retain `verified: true` and match original `conditionsHash`.
- Filter engine deduplication behaves deterministically.
6. Verify and output report to `agents/antigravity/done/TASK-2026-08-21-07-duplicate-comments-rule.md`.

View file

@ -21,18 +21,23 @@ export function applyFilterRules(
participants: RawParticipant[], participants: RawParticipant[],
rules: FilterRules rules: FilterRules
): FilterResult { ): FilterResult {
// 1. Deduplicate participants by platformUserId (if enabled, aggregate comment counts) // 1. Deduplicate participants by platformUserId (unconditional deduplication: 1 participant = 1 chance)
const participantMap = new Map<string, RawParticipant>(); const participantMap = new Map<string, RawParticipant>();
for (const p of participants) { for (const p of participants) {
const existing = participantMap.get(p.platformUserId); const existing = participantMap.get(p.platformUserId);
const pComments = typeof p.commentsCount === 'number' ? p.commentsCount : (p.commented ? 1 : 0);
if (!existing) { if (!existing) {
participantMap.set(p.platformUserId, { ...p }); participantMap.set(p.platformUserId, {
...p,
commentsCount: pComments,
});
} else { } else {
// Merge actions // Merge actions
existing.liked = existing.liked || p.liked; existing.liked = existing.liked || p.liked;
existing.commented = existing.commented || p.commented; existing.commented = existing.commented || p.commented;
existing.commentsCount = (existing.commentsCount || 0) + (p.commentsCount || 1); existing.commentsCount = (existing.commentsCount || 0) + pComments;
existing.reposted = existing.reposted || p.reposted; existing.reposted = existing.reposted || p.reposted;
existing.subscribed = existing.subscribed || p.subscribed; existing.subscribed = existing.subscribed || p.subscribed;
existing.isAdmin = existing.isAdmin || p.isAdmin; existing.isAdmin = existing.isAdmin || p.isAdmin;

View file

@ -27,13 +27,13 @@ export function sha256(content: string): string {
} }
/** /**
* Computes deterministic conditionsHash for given filter rules * Computes deterministic conditionsHash for given filter rules.
* Supports backward compatibility for legacy snapshots that stored excludeDuplicateComments.
*/ */
export function computeConditionsHash(rules: FilterRules): string { export function computeConditionsHash(rules: FilterRules | Record<string, any>): string {
const canonicalRules = { const canonicalRules: Record<string, any> = {
excludeAdmins: Boolean(rules.excludeAdmins), excludeAdmins: Boolean(rules.excludeAdmins),
excludeBlacklistedIds: [...(rules.excludeBlacklistedIds || [])].map(s => s.trim().toLowerCase()).sort(), excludeBlacklistedIds: [...(rules.excludeBlacklistedIds || [])].map(s => String(s).trim().toLowerCase()).sort(),
excludeDuplicateComments: Boolean(rules.excludeDuplicateComments),
minEligibleParticipants: rules.minEligibleParticipants ?? 1, minEligibleParticipants: rules.minEligibleParticipants ?? 1,
requireComment: Boolean(rules.requireComment), requireComment: Boolean(rules.requireComment),
requireLike: Boolean(rules.requireLike), requireLike: Boolean(rules.requireLike),
@ -42,6 +42,11 @@ export function computeConditionsHash(rules: FilterRules): string {
targetGroupId: rules.targetGroupId || null, targetGroupId: rules.targetGroupId || null,
}; };
// Backward compatibility: preserve legacy canonical key for snapshots stored with excludeDuplicateComments
if ('excludeDuplicateComments' in rules && rules.excludeDuplicateComments !== undefined) {
canonicalRules.excludeDuplicateComments = Boolean(rules.excludeDuplicateComments);
}
return sha256(canonicalStringify(canonicalRules)); return sha256(canonicalStringify(canonicalRules));
} }

View file

@ -19,7 +19,7 @@ export interface FilterRules {
targetGroupId?: string; targetGroupId?: string;
excludeAdmins: boolean; excludeAdmins: boolean;
excludeBlacklistedIds: string[]; // List of user IDs or usernames to exclude excludeBlacklistedIds: string[]; // List of user IDs or usernames to exclude
excludeDuplicateComments: boolean; // Count user only once even if multiple comments excludeDuplicateComments?: boolean; // @deprecated legacy field for backward compatibility
minEligibleParticipants?: number; minEligibleParticipants?: number;
} }
@ -30,7 +30,6 @@ export const DEFAULT_FILTER_RULES: FilterRules = {
requireSubscription: false, requireSubscription: false,
excludeAdmins: false, excludeAdmins: false,
excludeBlacklistedIds: [], excludeBlacklistedIds: [],
excludeDuplicateComments: true,
minEligibleParticipants: 1, minEligibleParticipants: 1,
}; };

View file

@ -9,7 +9,7 @@ export const filterRulesSchema = z.object({
requireRepost: z.boolean().default(false), requireRepost: z.boolean().default(false),
requireSubscription: z.boolean().default(false), requireSubscription: z.boolean().default(false),
excludeAdmins: z.boolean().default(false), excludeAdmins: z.boolean().default(false),
excludeDuplicateComments: z.boolean().default(true), excludeDuplicateComments: z.boolean().optional(),
excludeBlacklistedIds: z.array(z.string().max(128)).max(1000).default([]), excludeBlacklistedIds: z.array(z.string().max(128)).max(1000).default([]),
targetGroupId: z.string().max(128).optional(), targetGroupId: z.string().max(128).optional(),
minEligibleParticipants: z.number().int().min(1).max(100000).default(1), minEligibleParticipants: z.number().int().min(1).max(100000).default(1),
@ -21,7 +21,6 @@ const defaultRulesObject = {
requireRepost: false, requireRepost: false,
requireSubscription: false, requireSubscription: false,
excludeAdmins: false, excludeAdmins: false,
excludeDuplicateComments: true,
excludeBlacklistedIds: [] as string[], excludeBlacklistedIds: [] as string[],
minEligibleParticipants: 1, minEligibleParticipants: 1,
}; };

View file

@ -0,0 +1,265 @@
import { describe, it, expect } from 'vitest';
import { applyFilterRules } from '../src/core/filtering/filter-engine';
import { RawParticipant } from '../src/core/types/participant';
import { FilterRules, DEFAULT_FILTER_RULES } from '../src/core/types/giveaway';
import { computeConditionsHash } from '../src/core/randomizer/canonical';
import { verifyDrawResult, executeDeterministicDrawV1 } from '../src/core/randomizer/deterministic';
import { computeParticipantsSnapshotHash } from '../src/core/randomizer/canonical';
describe('Task 07: Duplicate Comments Rule & Backward Compatibility', () => {
// ─── 1. commentsCount merging accuracy (Fix || 1 bug) ─────────────────────────
it('duplicate participant entry with commentsCount: 0 does not artificially increment commentsCount', () => {
const rawEntries: RawParticipant[] = [
{
platformUserId: 'user_like_only',
firstName: 'Like',
lastName: 'Only',
source: 'LIKES',
liked: true,
commented: false,
commentsCount: 0,
reposted: false,
subscribed: true,
},
{
platformUserId: 'user_like_only',
firstName: 'Like',
lastName: 'Only',
source: 'LIKES',
liked: true,
commented: false,
commentsCount: 0,
reposted: false,
subscribed: true,
},
];
const result = applyFilterRules(rawEntries, DEFAULT_FILTER_RULES);
expect(result.allParticipants).toHaveLength(1);
expect(result.allParticipants[0].commentsCount).toBe(0);
});
it('merging like entry (0 comments) and comment entry (1 comment) accurately sums commentsCount to 1', () => {
const rawEntries: RawParticipant[] = [
{
platformUserId: 'user_mixed',
firstName: 'Mixed',
lastName: 'User',
source: 'LIKES',
liked: true,
commented: false,
commentsCount: 0,
reposted: false,
subscribed: true,
},
{
platformUserId: 'user_mixed',
firstName: 'Mixed',
lastName: 'User',
source: 'COMMENTS',
liked: false,
commented: true,
commentsCount: 1,
reposted: false,
subscribed: true,
},
];
const result = applyFilterRules(rawEntries, DEFAULT_FILTER_RULES);
expect(result.allParticipants).toHaveLength(1);
expect(result.allParticipants[0].liked).toBe(true);
expect(result.allParticipants[0].commented).toBe(true);
expect(result.allParticipants[0].commentsCount).toBe(1);
});
// ─── 2. Unconditional deduplication (1 user = 1 chance) ──────────────────────
it('unconditionally deduplicates participants so 1 user gets 1 chance regardless of comment volume', () => {
const rawEntries: RawParticipant[] = Array.from({ length: 10 }, (_, i) => ({
platformUserId: 'spammer_100',
firstName: 'Spammer',
lastName: 'User',
source: 'COMMENTS',
liked: true,
commented: true,
commentsCount: 1,
reposted: false,
subscribed: true,
}));
const result = applyFilterRules(rawEntries, {
...DEFAULT_FILTER_RULES,
requireComment: true,
});
expect(result.allParticipants).toHaveLength(1);
expect(result.eligibleParticipants).toHaveLength(1);
expect(result.allParticipants[0].commentsCount).toBe(10);
expect(result.allParticipants[0].eligible).toBe(true);
});
// ─── 3. Legacy snapshot verification compatibility ────────────────────────────
it('legacy snapshot with excludeDuplicateComments: true retains verified: true in verifyDrawResult', () => {
// 1. Simulate legacy filterRulesSnapshot with excludeDuplicateComments field
const legacyFilterRules = {
requireLike: true,
requireComment: false,
requireRepost: false,
requireSubscription: false,
excludeAdmins: false,
excludeBlacklistedIds: [],
excludeDuplicateComments: true,
minEligibleParticipants: 1,
};
const conditionsHash = computeConditionsHash(legacyFilterRules);
const eligibleParticipants = [
{
platformUserId: '101',
firstName: 'Alice',
lastName: 'A',
source: 'LIKES' as const,
liked: true,
commented: false,
commentsCount: 0,
reposted: false,
subscribed: true,
eligible: true,
exclusionReason: null,
},
{
platformUserId: '102',
firstName: 'Bob',
lastName: 'B',
source: 'LIKES' as const,
liked: true,
commented: false,
commentsCount: 0,
reposted: false,
subscribed: true,
eligible: true,
exclusionReason: null,
},
];
const participantsSnapshotHash = computeParticipantsSnapshotHash(eligibleParticipants);
const legacySnapshot = {
id: 'snap_legacy_001',
giveawayId: 'gw_legacy_001',
version: 1,
createdAt: '2026-08-15T12:00:00.000Z',
eligibleParticipants,
filterRulesSnapshot: legacyFilterRules,
participantCount: 2,
participantsSnapshotHash,
conditionsHash,
};
const seed = 'legacy_test_seed_1234567890abcdef1234567890abcdef';
// Execute draw with legacy snapshot
const drawResult = executeDeterministicDrawV1({
giveawayId: 'gw_legacy_001',
snapshot: legacySnapshot,
totalLoadedCount: 2,
winnersCount: 1,
reserveWinnersCount: 0,
seed,
});
// Independent verifyDrawResult replay
const verification = verifyDrawResult({
giveawayId: 'gw_legacy_001',
drawId: drawResult.drawId,
drawnAt: drawResult.drawnAt,
snapshot: legacySnapshot,
seed,
claimedWinnersCount: 1,
claimedReserveCount: 0,
claimedWinnerIds: drawResult.winnerIds,
claimedReserveWinnerIds: drawResult.reserveWinnerIds,
claimedDeterministicProofHash: drawResult.deterministicProofHash,
claimedAuditEventHash: drawResult.auditEventHash,
algorithmVersion: drawResult.algorithmVersion,
});
expect(verification.conditionsIntegrity).toBe(true);
expect(verification.participantsSnapshotIntegrity).toBe(true);
expect(verification.verified).toBe(true);
});
// ─── 4. New snapshot verification compatibility ───────────────────────────────
it('new snapshot without excludeDuplicateComments computes clean hash and verifies successfully', () => {
const newFilterRules: FilterRules = {
requireLike: true,
requireComment: true,
requireRepost: false,
requireSubscription: false,
excludeAdmins: false,
excludeBlacklistedIds: ['999'],
minEligibleParticipants: 1,
};
const conditionsHash = computeConditionsHash(newFilterRules);
const eligibleParticipants = [
{
platformUserId: '201',
firstName: 'Charlie',
lastName: 'C',
source: 'COMMENTS' as const,
liked: true,
commented: true,
commentsCount: 2,
reposted: false,
subscribed: true,
eligible: true,
exclusionReason: null,
},
];
const participantsSnapshotHash = computeParticipantsSnapshotHash(eligibleParticipants);
const newSnapshot = {
id: 'snap_new_001',
giveawayId: 'gw_new_001',
version: 1,
createdAt: '2026-08-21T12:00:00.000Z',
eligibleParticipants,
filterRulesSnapshot: newFilterRules,
participantCount: 1,
participantsSnapshotHash,
conditionsHash,
};
const seed = 'new_test_seed_1234567890abcdef1234567890abcdef';
const drawResult = executeDeterministicDrawV1({
giveawayId: 'gw_new_001',
snapshot: newSnapshot,
totalLoadedCount: 1,
winnersCount: 1,
reserveWinnersCount: 0,
seed,
});
const verification = verifyDrawResult({
giveawayId: 'gw_new_001',
drawId: drawResult.drawId,
drawnAt: drawResult.drawnAt,
snapshot: newSnapshot,
seed,
claimedWinnersCount: 1,
claimedReserveCount: 0,
claimedWinnerIds: drawResult.winnerIds,
claimedReserveWinnerIds: drawResult.reserveWinnerIds,
claimedDeterministicProofHash: drawResult.deterministicProofHash,
claimedAuditEventHash: drawResult.auditEventHash,
algorithmVersion: drawResult.algorithmVersion,
});
expect(verification.conditionsIntegrity).toBe(true);
expect(verification.verified).toBe(true);
});
});