From b0d06d8117c712b904e194c5ca1c6a4f9759f5ec Mon Sep 17 00:00:00 2001
From: Weslyn Whitehead
diff --git a/js/contacts.js b/js/contacts.js index 7039c9d..e4dbb51 100644 --- a/js/contacts.js +++ b/js/contacts.js @@ -522,15 +522,8 @@ function bindContactActionEvents(content) { function renderContactRows(rows) { const content = document.getElementById('contactsListContent'); if (!content) return; - // Trust mode normalizes each row's raw trust_score against the max across - // the currently visible rows, mirroring the 0..100 normalization done - // server-side by get_contact_trust_summary. - const maxTrustScore = rows.reduce( - (acc, r) => Math.max(acc, Number(r.contact.trust_score) || 0), - 0 - ); content.innerHTML = rows.map(({ contact, profile, shared }) => ( - renderContactRow(contact, profile, shared, maxTrustScore) + renderContactRow(contact, profile, shared) )).join(''); bindContactRowEvents(content); bindContactActionEvents(content); @@ -1069,20 +1062,29 @@ function bindContactDragSort(content) { }); } +// Maps a raw (weighted, time-decayed) trust score onto the absolute 0..100 +// scale used everywhere in the UI: 100 * (1 - 2^(-raw / half)). Mirrors the +// formula in get_contact_trust_summary (sql/contact-details-schema.sql); +// keep TRUST_SCORE_HALF_RAW in sync with c_score_half_raw there. +const TRUST_SCORE_HALF_RAW = 4.0; +function trustScoreFromRaw(raw) { + const r = Math.max(0, Number(raw) || 0); + const score = Math.round(100 * (1 - Math.pow(2, -r / TRUST_SCORE_HALF_RAW))); + return Math.max(0, Math.min(100, score)); +} + // Right-edge content for a contact row depends on the active sort mode so the -// data the user is sorting by is always visible. Trust mode normalizes the -// raw stored score against the caller's max so it lands on a 0..100 scale, -// matching how get_contact_trust_summary normalizes for the details ring. -function renderContactRowRightEdge(contact, knownDuration, lastSeen, maxTrustScore) { +// data the user is sorting by is always visible. Trust mode maps the raw +// stored score onto the same absolute 0..100 scale the details ring uses, +// so the list and the Contact Details screen always agree. +function renderContactRowRightEdge(contact, knownDuration, lastSeen) { if (contactsSortMode === 'age') { return knownDuration ? `${esc(knownDuration)}` : ''; } if (contactsSortMode === 'trust') { - const raw = Number(contact.trust_score) || 0; - const max = Number(maxTrustScore) || 0; - const score = max > 0 ? Math.round((raw / max) * 100) : 0; + const score = trustScoreFromRaw(contact.trust_score); const r = 14; const c = 2 * Math.PI * r; const offset = c - (score / 100) * c; @@ -1102,7 +1104,7 @@ function renderContactRowRightEdge(contact, knownDuration, lastSeen, maxTrustSco return lastSeen ? `${esc(lastSeen)}` : ''; } -function renderContactRow(contact, profile, shared, maxTrustScore) { +function renderContactRow(contact, profile, shared) { const name = profile.display_name || 'Unknown'; const avatarUrl = profile.profile_image_url || null; const phone = (shared.shared_phone != null && shared.shared_phone !== '') ? shared.shared_phone : ''; @@ -1113,7 +1115,7 @@ function renderContactRow(contact, profile, shared, maxTrustScore) { const lastSeen = formatLastSeen(contact.met_at); const knownSinceDateStr = contact.first_met_at || contact.created_at || null; const knownDuration = formatKnownDuration(knownSinceDateStr); - const rightEdgeHtml = renderContactRowRightEdge(contact, knownDuration, lastSeen, maxTrustScore); + const rightEdgeHtml = renderContactRowRightEdge(contact, knownDuration, lastSeen); const firstMetValue = knownSinceDateStr ? new Date(knownSinceDateStr).toISOString().slice(0, 10) : ''; const firstMetDisplayValue = formatFirstMetDisplay(knownSinceDateStr); const avatarHtml = avatarUrl diff --git a/sql/contact-details-schema.sql b/sql/contact-details-schema.sql index 9449244..bac96ee 100644 --- a/sql/contact-details-schema.sql +++ b/sql/contact-details-schema.sql @@ -64,6 +64,23 @@ DECLARE c_decay_a CONSTANT double precision := ln(2.0) / 2.0; -- = ln(2) / c_half_life_years c_seconds_per_year CONSTANT double precision := 31556952.0; -- Julian year (365.2425d) + -- ===== SCORE SCALE (HALF-POINT = 4.0) ===================================== + -- combined_raw maps onto 0..100 through a saturating curve: + -- score = 100 * (1 - 2^(-combined_raw / c_score_half_raw)) + -- Each additional c_score_half_raw of combined_raw closes half the + -- remaining distance to 100 -- the same halving convention as the time + -- decay above, applied to vouch mass instead of age. With the default + -- weights, two fresh direct vouches (combined_raw = 4) score 50, four + -- score 75, and very strong ties approach 100 asymptotically. + -- + -- The scale is ABSOLUTE: a contact's score depends only on vouches + -- involving that contact, so it never shifts because an unrelated contact + -- gained or lost vouches, and the same number means the same thing for + -- every user. (The previous scheme divided by the caller's max, which + -- pinned the top contact at 100 even with a single stale vouch and made + -- every score move when any other contact's vouches changed.) + c_score_half_raw CONSTANT double precision := 4.0; + -- ===== COMPONENT WEIGHTS =================================================== -- combined_raw = v_w_direct * direct_sum -- + v_w_mutuals * mutuals_sum @@ -263,10 +280,11 @@ BEGIN -- combined_raw = v_w_direct*DIRECT + v_w_mutuals*MUTUALS + v_w_trusted*TRUSTED -- -- We recompute combined_raw for EVERY one of the caller's contacts on each - -- call so the persisted contacts.trust_score column stays current and we - -- have a fresh max for normalization. The displayed score is then - -- round(100 * combined_raw / max_combined_raw across my contacts) - -- guarded against divide-by-zero. + -- call so the persisted contacts.trust_score column stays current for the + -- contact-list ring and a future "score changed" trigger. The displayed + -- score maps combined_raw onto an absolute 0..100 scale (see SCORE SCALE + -- block above): + -- round(100 * (1 - 2^(-combined_raw / c_score_half_raw))) -- =========================================================================== WITH @@ -395,7 +413,8 @@ BEGIN ) ); - -- Read back combined_raw + max from the just-updated cache. + -- Read back combined_raw from the just-updated cache. max_combined_raw is + -- still returned for transparency/debugging but no longer feeds the score. SELECT COALESCE(c.trust_score, 0), COALESCE((SELECT MAX(trust_score) FROM public.contacts WHERE user_id = v_caller_id), 0) @@ -403,14 +422,11 @@ BEGIN FROM public.contacts c WHERE c.user_id = v_caller_id AND c.contact_id = p_contact_id; - -- Normalize 0..100; guard divide-by-zero (every score is 0). - IF v_max_combined_raw IS NULL OR v_max_combined_raw <= 0 THEN - v_score := 0; - ELSE - v_score := GREATEST(0, LEAST(100, - ROUND(100.0 * v_combined_raw / v_max_combined_raw)::int - )); - END IF; + -- Absolute 0..100 scale; see SCORE SCALE block above. Depends only on this + -- contact's combined_raw, so the number is stable and comparable. + v_score := GREATEST(0, LEAST(100, + ROUND(100.0 * (1.0 - POWER(2.0, -GREATEST(v_combined_raw, 0) / c_score_half_raw)))::int + )); RETURN json_build_object( 'score', v_score,