Docs/Skills and code/src/

src/analysis/within-channel-statistics.js

Code133 lines5.2 KBsrc/

const DAY_MS = 86_400_000;

export const SAMPLE_BANDS = Object.freeze([
  { min: 0, max: 4, label: "insufficient_evidence" },
  { min: 5, max: 9, label: "preliminary_distribution" },
  { min: 10, max: 19, label: "initial_outlier_analysis" },
  { min: 20, max: 49, label: "distribution_analysis" },
  { min: 50, max: Infinity, label: "subgroup_analysis_possible" }
]);

export function isFiniteNumber(value) {
  return value !== null && value !== undefined && value !== "" && Number.isFinite(Number(value));
}

export function numberOrNull(value) {
  return isFiniteNumber(value) ? Number(value) : null;
}

export function round(value, digits = 6) {
  return value == null || !Number.isFinite(value) ? null : Number(value.toFixed(digits));
}

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

export function quantile(values, p) {
  const sorted = values.filter(Number.isFinite).sort((a, b) => a - b);
  if (!sorted.length) return null;
  const position = Math.max(0, Math.min(1, p)) * (sorted.length - 1);
  const lower = Math.floor(position);
  const upper = Math.ceil(position);
  if (lower === upper) return sorted[lower];
  return sorted[lower] + (sorted[upper] - sorted[lower]) * (position - lower);
}

export function medianAbsoluteDeviation(values) {
  const center = median(values);
  return center == null ? null : median(values.filter(Number.isFinite).map(value => Math.abs(value - center)));
}

export function robustZ(value, values) {
  if (!Number.isFinite(value) || values.length < 2) return { score: null, mad: null, center: median(values) };
  const center = median(values);
  const mad = medianAbsoluteDeviation(values);
  if (center == null || mad == null || mad <= 0) return { score: null, mad, center };
  return { score: 0.67448975 * (value - center) / mad, mad, center };
}

export function sampleAssessment(n) {
  return SAMPLE_BANDS.find(band => n >= band.min && n <= band.max)?.label ?? "insufficient_evidence";
}

export function reliablePublicationDate(video) {
  const precision = String(video.published_at_precision ?? "").toLowerCase();
  const method = String(video.published_at_normalization_method ?? "").toLowerCase();
  if (!(precision === "exact" || precision === "date" || precision.startsWith("exact_"))) return null;
  if (method.includes("relative") || method.includes("unrecognized")) return null;
  const value = video.published_at_normalized || video.published_at;
  if (!value) return null;
  const time = Date.parse(value);
  return Number.isFinite(time) ? time : null;
}

export function ageCohort(ageDays) {
  if (!Number.isFinite(ageDays) || ageDays < 0) return "unknown";
  if (ageDays <= 7) return "0_7d";
  if (ageDays <= 30) return "8_30d";
  if (ageDays <= 90) return "31_90d";
  if (ageDays <= 365) return "91_365d";
  if (ageDays <= 1095) return "1_3y";
  return "3y_plus";
}

export function outlierLabel(z, ratio, sampleSize, mad) {
  if (sampleSize < 5) return "insufficient_sample";
  if (z != null) {
    if (z >= 3.5) return "strong_outlier";
    if (z >= 2.5) return "potential_outlier";
    return "not_outlier";
  }
  // A zero-MAD distribution cannot support a robust z-score. Use an explicit,
  // conservative ratio fallback and record the fallback method for auditability.
  if (mad === 0 && ratio != null) {
    if (ratio >= 5) return "strong_outlier_ratio_fallback";
    if (ratio >= 3) return "potential_outlier_ratio_fallback";
    return "not_outlier_ratio_fallback";
  }
  return "unavailable";
}

export function viewRatio(value, baseline) {
  return baseline != null && baseline > 0 ? value / baseline : null;
}

export function concentrationShare(sortedDesc, topN, total) {
  if (!(total > 0) || !sortedDesc.length) return null;
  return sortedDesc.slice(0, topN).reduce((sum, video) => sum + video.numeric_views, 0) / total;
}

function getAgeDays(video, nowMs) {
  const publishedMs = reliablePublicationDate(video);
  if (publishedMs == null) return null;
  const observedMs = Date.parse(video.view_observed_at || "");
  const referenceMs = Number.isFinite(observedMs) ? observedMs : nowMs;
  const age = (referenceMs - publishedMs) / DAY_MS;
  if (!Number.isFinite(age) || age < -1) return null;
  return Math.max(1, age);
}

export function prepareVideos(videos, nowMs) {
  const uniqueVideos = [...new Map(videos.filter(video => video?.video_id).map(video => [video.video_id, video])).values()];
  return uniqueVideos.map(video => {
    const views = numberOrNull(video.view_count);
    const ageDays = getAgeDays(video, nowMs);
    const viewsPerDay = views != null && ageDays != null ? views / ageDays : null;
    const cohort = ageDays == null ? "unknown" : ageCohort(ageDays);
    return {
      ...video,
      numeric_views: views != null && views >= 0 ? views : null,
      age_days: ageDays,
      age_cohort: cohort,
      views_per_day: views != null && views >= 0 && ageDays != null ? viewsPerDay : null,
      log_views: views != null && views >= 0 ? Math.log1p(views) : null,
      log_views_per_day: views != null && views >= 0 && ageDays != null ? Math.log1p(viewsPerDay) : null,
      date_reliable: ageDays != null
    };
  });
}

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.