Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions js/contact-details.js
Original file line number Diff line number Diff line change
Expand Up @@ -739,8 +739,11 @@ function cdOpenTrustInfoDialog() {
three vouch-based signals. Every vouch counts, but vouches
fade with age &mdash; a vouch from <strong>two years ago is
worth half</strong> of one made today (a half-life of two
years). The score you see is normalized 0&ndash;100 against
your most-connected contact.
years). The combined total maps onto a fixed 0&ndash;100
scale where each extra helping of vouch weight closes
<strong>half the remaining distance</strong> to 100 &mdash;
so this score only moves when vouches involving this
contact change.
</p>

<p class="cd-trust-info-foot">
Expand Down
36 changes: 19 additions & 17 deletions js/contacts.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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
? `<span class="contact-row-known">${esc(knownDuration)}</span>`
: '';
}
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;
Expand All @@ -1102,7 +1104,7 @@ function renderContactRowRightEdge(contact, knownDuration, lastSeen, maxTrustSco
return lastSeen ? `<span class="contact-row-lastseen">${esc(lastSeen)}</span>` : '';
}

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 : '';
Expand All @@ -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
Expand Down
42 changes: 29 additions & 13 deletions sql/contact-details-schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -395,22 +413,20 @@ 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)
INTO v_combined_raw, v_max_combined_raw
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,
Expand Down