Docs/Skills and code/src/

src/analysis/channel-priority-scoring.js

Code161 lines9.2 KBsrc/

import { ageInDays, isRecencyEligible } from "./temporal.js";
import { unitVector, cosineOfUnitVectors } from "./niche-clustering.js";

const clamp = (value, min = 0, max = 100) => Math.max(min, Math.min(max, value));
const round = (value, digits = 4) => Number.isFinite(value) ? Number(value.toFixed(digits)) : null;

function median(values) {
  const clean = values.filter(Number.isFinite).sort((a, b) => a - b);
  if (!clean.length) return null;
  const mid = Math.floor(clean.length / 2);
  return clean.length % 2 ? clean[mid] : (clean[mid - 1] + clean[mid]) / 2;
}

/** Scores channels from embeddings, title clusters and available performance evidence. */
export function scoreChannels({
  channelRows, videoIdsByChannel, channelUrlsByVideo, videos, clusters,
  membershipByVideo, performanceVideoById, minVideos = 5,
  minClusterSize = 3, now = new Date(), scoringVersion = "channel-priority-v1"
}) {
  const vectorById = new Map(videos.map(video => [video.video_id, video.vector]));
  const clusterByNo = new Map(clusters.map(cluster => [cluster.clusterNo, cluster]));
  const totalChannels = Math.max(1, channelRows.length);

  for (const cluster of clusters) {
    const channelsInCluster = new Set();
    for (const member of cluster.members) {
      for (const channelUrl of channelUrlsByVideo.get(member.video.video_id) ?? []) channelsInCluster.add(channelUrl);
    }
    cluster.channelCount = channelsInCluster.size;
  }

  const scores = [];
  for (const channel of channelRows) {
    const allVideoIds = [...(videoIdsByChannel.get(channel.channel_url) ?? [])];
    const channelVectors = allVideoIds.map(id => vectorById.get(id)).filter(Boolean);
    const embeddedCount = channelVectors.length;
    const sampledCount = allVideoIds.length;
    const assignedClusters = new Map();
    for (const videoId of allVideoIds) {
      const membership = membershipByVideo.get(videoId);
      if (membership) assignedClusters.set(membership.clusterNo, (assignedClusters.get(membership.clusterNo) ?? 0) + 1);
    }

    let meanTitleCoherence = null;
    let coherenceComponent = null;
    if (embeddedCount >= minVideos) {
      const sum = new Float64Array(channelVectors[0].length);
      for (const vector of channelVectors) for (let i = 0; i < sum.length; i++) sum[i] += vector[i];
      const centroid = unitVector(sum);
      meanTitleCoherence = channelVectors.reduce((total, vector) => total + (cosineOfUnitVectors(vector, centroid) ?? 0), 0) / embeddedCount;
      coherenceComponent = clamp(((meanTitleCoherence - 0.30) / 0.55) * 100);
    }

    const clusterCounts = [...assignedClusters.values()];
    const clusterTotal = clusterCounts.reduce((sum, count) => sum + count, 0);
    const dominantClusterShare = clusterTotal ? Math.max(...clusterCounts) / clusterTotal : null;
    let concentration = null;
    if (clusterCounts.length) {
      if (clusterCounts.length === 1) concentration = 1;
      else {
        const entropy = clusterCounts.reduce((sum, count) => {
          const p = count / clusterTotal;
          return sum - p * Math.log(p);
        }, 0);
        concentration = clamp(100 * (1 - entropy / Math.log(clusterCounts.length))) / 100;
      }
    }
    const specialistScore = coherenceComponent == null || concentration == null
      ? null : clamp(0.60 * coherenceComponent + 0.40 * concentration * 100);

    let noveltyWeighted = 0;
    let noveltyWeight = 0;
    for (const [clusterNo, count] of assignedClusters) {
      const cluster = clusterByNo.get(clusterNo);
      if (!cluster || cluster.members.length < minClusterSize) continue;
      const rarity = totalChannels <= 1 ? 0 : clamp(100 * (
        1 - Math.log1p(Math.max(1, cluster.channelCount)) / Math.log1p(totalChannels)
      ));
      noveltyWeighted += rarity * count;
      noveltyWeight += count;
    }
    const noveltyScore = noveltyWeight ? noveltyWeighted / noveltyWeight : null;

    // Performance evidence is independent of embedding coverage.
    const channelVideoRows = allVideoIds.map(id => performanceVideoById.get(id)).filter(Boolean);
    const performanceRows = channelVideoRows.filter(row => Number(row.view_count) > 0);
    const ageAdjustedRows = performanceRows.map(row => {
      const eligible = isRecencyEligible(row.published_at_precision);
      const ageDays = eligible ? ageInDays(row.published_at_normalized, now) : null;
      return ageDays ? { ...row, performanceValue: Number(row.view_count) / ageDays } : null;
    }).filter(Boolean);
    let performanceBasis = "insufficient_data";
    let performanceMetrics = [];
    if (ageAdjustedRows.length >= 3) {
      performanceBasis = "lifetime_average_views_per_day";
      performanceMetrics = ageAdjustedRows.map(row => ({ videoId: row.video_id, value: row.performanceValue }));
    } else if (performanceRows.length >= 3) {
      performanceBasis = "within_channel_view_count_snapshot";
      performanceMetrics = performanceRows.map(row => ({ videoId: row.video_id, value: Number(row.view_count) }));
    }
    let breakthroughScore = null;
    let maxRelativePerformance = null;
    let outlierShare = null;
    let performanceBaseline = null;
    if (performanceMetrics.length >= 3) {
      performanceBaseline = median(performanceMetrics.map(row => row.value));
      const ratios = performanceMetrics.map(row => performanceBaseline > 0 ? row.value / performanceBaseline : 1);
      maxRelativePerformance = Math.max(...ratios);
      outlierShare = ratios.filter(ratio => ratio >= 3).length / ratios.length;
      breakthroughScore = clamp(25 * Math.log2(Math.max(1, maxRelativePerformance)) + 30 * outlierShare);
    }

    const performanceCoverage = sampledCount ? performanceRows.length / sampledCount : 0;
    const dateCoverage = performanceRows.length ? ageAdjustedRows.length / performanceRows.length : 0;
    const embeddingCoverage = sampledCount ? embeddedCount / sampledCount : 0;
    const evidenceQuality = clamp(100 * (0.55 * embeddingCoverage + 0.25 * performanceCoverage + 0.20 * dateCoverage));
    const explorationScore = clamp(100 * (1 - Math.min(embeddedCount / 20, 1)));
    const components = [
      { key: "specialist", value: specialistScore, weight: 0.40 },
      { key: "breakthrough", value: breakthroughScore, weight: 0.30 },
      { key: "novelty", value: noveltyScore, weight: 0.20 },
      { key: "exploration", value: explorationScore, weight: 0.10 }
    ].filter(component => component.value != null);
    const availableWeight = components.reduce((sum, component) => sum + component.weight, 0);
    const priorityScore = embeddedCount >= minVideos && availableWeight > 0
      ? components.reduce((sum, component) => sum + component.value * component.weight, 0) / availableWeight
      : null;

    const dominantClusterNo = assignedClusters.size
      ? [...assignedClusters.entries()].sort((a, b) => b[1] - a[1] || a[0] - b[0])[0][0]
      : null;
    const missingSignals = [];
    if (embeddedCount < minVideos) missingSignals.push(`needs at least ${minVideos} embedded titles for stable specialist ranking`);
    if (performanceRows.length < 3) missingSignals.push("fewer than three videos have view counts; breakthrough score withheld");
    else if (ageAdjustedRows.length < 3) missingSignals.push("insufficient usable publication dates for age-adjusted performance; using raw view snapshots");
    if (noveltyScore == null) missingSignals.push("no supported multi-video niche cluster for novelty scoring");

    scores.push({
      channelUrl: channel.channel_url, channel: channel.name,
      specialistScore: round(specialistScore, 2), breakthroughScore: round(breakthroughScore, 2),
      noveltyScore: round(noveltyScore, 2), explorationScore: round(explorationScore, 2),
      evidenceQualityScore: round(evidenceQuality, 2), priorityScore: round(priorityScore, 2),
      confidence: round(evidenceQuality / 100, 3), embeddedVideoCount: embeddedCount,
      sampledVideoCount: sampledCount, performanceVideoCount: performanceRows.length,
      ageAdjustedPerformanceCount: ageAdjustedRows.length, dominantClusterNo,
      dominantClusterShare: round(dominantClusterShare, 3), meanTitleCoherence: round(meanTitleCoherence, 4),
      rationale: {
        scoringVersion, rankingUsesHumanAnnotations: false,
        specialist: { titleCoherenceComponent: round(coherenceComponent, 2), clusterConcentration: round(concentration, 3), formula: "0.60 * title coherence + 0.40 * cluster concentration" },
        breakthrough: { score: round(breakthroughScore, 2), basis: performanceBasis, maxRelativePerformance: round(maxRelativePerformance, 2), outlierShare: round(outlierShare, 3), baseline: round(performanceBaseline, 3) },
        novelty: { score: round(noveltyScore, 2), basis: "rarity of supported multi-video semantic clusters across distinct channels" },
        exploration: { score: round(explorationScore, 2), basis: "higher when title coverage is low, to preserve discovery slots" },
        evidenceQuality: { embeddingCoverage: round(embeddingCoverage, 3), viewCountCoverage: round(performanceCoverage, 3), usableDateCoverageAmongViewedVideos: round(dateCoverage, 3) },
        priorityComponents: components.map(({ key, value, weight }) => ({ key, value: round(value, 2), configuredWeight: weight })),
        availableWeightRenormalizedToOne: round(availableWeight, 3), missingSignals
      }
    });
  }
  return scores;
}

Shown as it is in the repository at build time (2026-11-03); the home directory appears as <home>. If this page and the file differ, the file is right. Back to the catalogue.