All files / src/server/services ScoringService.ts

97.61% Statements 41/42
94.44% Branches 17/18
100% Functions 5/5
100% Lines 34/34

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125                      37x                   37x               133x       124x       124x 60x 30x 14x                                         73x 73x 179x 179x 126x 126x   179x         73x     73x 162x 133x 133x 133x 133x             73x 162x   25x   25x   23x 23x     73x 73x 126x     126x 124x                               73x      
import type {
  CollectionPreferenceItem,
  DecisionEvent,
  PairwiseDecision,
  TriageDecision,
} from "@/types";
import { compareByCreatedAtThenId } from "@/lib/decision-order";
 
// Triage seeds each item at the CENTER of its tier band, so triage -> getBucket
// is an identity round-trip and every item has +/-0.125 of headroom for pairwise
// refinement to move it within, or across, a tier boundary.
const TIER_SEED = {
  top: 0.875,
  high: 0.625,
  medium: 0.375,
  low: 0.125,
} as const;
 
// Defensive only: an item compared without ever being triaged cannot happen in
// the real flow (the whole collection is triaged before comparison). Seed such
// an item at the neutral Maybe center.
const UNTRIAGED_DEFAULT = TIER_SEED.medium;
 
type ItemState = { score: number; count: number };
 
export class ScoringService {
  private scoreFromTriageEvent(
    result: "top" | "high" | "medium" | "low",
  ): number {
    return TIER_SEED[result];
  }
 
  private calculateConfidence(comparisonCount: number): number {
    return Math.min(1.0, comparisonCount / 10);
  }
 
  private getBucket(score: number): "top" | "high" | "medium" | "low" {
    if (score >= 0.75) return "top";
    if (score >= 0.5) return "high";
    if (score >= 0.25) return "medium";
    return "low";
  }
 
  // `events` is the curator's decision log, read once by the caller through the shared loader
  // (decision-log.ts) and shared with the comparison mapping.
  //
  // purses-5g1g: `liveItemIds` is the collection's live (non-deleted) item set.
  // decision_events are append-only and SURVIVE an item purge (ItemPurgeService
  // hard-deletes the item + its preferences, never its events), so a recompute
  // over the surviving events would otherwise emit a preference row for a
  // now-nonexistent item — an FK violation on preferences.item_id (no cascade)
  // that rolls back the whole tap. The derived preference set must never include
  // an item that no longer exists, so drop any itemId outside the live set at the
  // emit point. Events for a purged item still fold into their opponents' counts,
  // so LIVE items' scores/confidence are unchanged (mirrors deriveUsage).
  async computePreferences(
    collectionId: string,
    curatorId: string,
    liveItemIds: ReadonlySet<string>,
    events: DecisionEvent[],
  ): Promise<CollectionPreferenceItem[]> {
    const itemState = new Map<string, ItemState>();
    const ensure = (itemId: string): ItemState => {
      let state = itemState.get(itemId);
      if (!state) {
        state = { score: UNTRIAGED_DEFAULT, count: 0 };
        itemState.set(itemId, state);
      }
      return state;
    };
 
    // Chronological order so the LAST triage per item wins its bucket. Ties on
    // createdAt fall back to id via the shared comparator (spec §2.4).
    const ordered = [...events].sort(compareByCreatedAtThenId);
 
    // Pass 1: triage seeds the score at its tier center — latest triage wins.
    for (const decision of ordered) {
      if (decision.type !== "triage") continue;
      const { itemId, result } = decision.decision as TriageDecision;
      const state = ensure(itemId);
      state.score = this.scoreFromTriageEvent(result);
      state.count++;
    }
 
    // Pass 2: pairwise no longer moves score — end-to-end ranking order is now
    // via orderRank + finalize (Epic 0 Tasks 7/10). We keep counting comparisons
    // as evidence for confidence: win/loss/tie increment both items; skip is a
    // no-op.
    for (const decision of ordered) {
      if (decision.type !== "pairwise_compare") continue;
      const { itemAId, itemBId, result } =
        decision.decision as PairwiseDecision;
 
      if (result === "skip") continue;
 
      ensure(itemAId).count++;
      ensure(itemBId).count++;
    }
 
    const preferences: CollectionPreferenceItem[] = [];
    for (const [itemId, data] of itemState) {
      Iif (data.count === 0) continue;
      // purses-5g1g: never emit a row for a purged/deleted item — its surviving
      // events counted toward live opponents above, but it owns no live row.
      if (!liveItemIds.has(itemId)) continue;
      preferences.push({
        id: `${collectionId}-${curatorId}-${itemId}`,
        collectionId,
        curatorId,
        itemId,
        score: data.score,
        confidence: this.calculateConfidence(data.count),
        rankBucket: this.getBucket(data.score),
        comparisonCount: data.count,
        orderRank: null,
        lastSeen: new Date(),
        createdAt: new Date(),
        updatedAt: new Date(),
      });
    }
 
    return preferences;
  }
}