#!/usr/bin/env node
/**
 * Discipline metrics — pure aggregation over observer episodes.
 * Stage 2 of router discipline overhaul (spec 2026-05-23): baseline measurement
 * перед enforcement в этапе 3.
 *
 * Pure / read-only. No exec, no fs.
 */

/** Filter helper: only schema v2+ non-error episodes. */
function valid(episodes) {
  return (episodes || []).filter(
    (e) => e && !e.observer_error && typeof e.schema_version === 'number' && e.schema_version >= 2
  );
}

/**
 * % эпизодов с матченным триггером, разбивка по task_classification.
 * Только классификации, присутствующие в classificationMap (т.е. известные/имеющие узлы).
 *
 * @param {object[]} episodes
 * @param {object} classificationMap  { [classification]: string[] }
 * @returns {{ [classification]: { episodes: number, withTriggerMatch: number, viaSkill: number, pctTriggerMatch: number, pctViaSkill: number } }}
 */
export function disciplinePercentByClassification(episodes, classificationMap) {
  const out = {};
  for (const e of valid(episodes)) {
    const pr = e.primary_rationale || {};
    const cls = pr.task_classification;
    if (!cls || !classificationMap[cls]) continue;
    if (!out[cls]) out[cls] = { episodes: 0, withTriggerMatch: 0, viaSkill: 0, pctTriggerMatch: 0, pctViaSkill: 0 };
    const b = out[cls];
    b.episodes += 1;
    if (Array.isArray(pr.triggers_matched) && pr.triggers_matched.length > 0) b.withTriggerMatch += 1;
    if (pr.node_chosen && pr.node_chosen !== 'direct') b.viaSkill += 1;
  }
  for (const b of Object.values(out)) {
    b.pctTriggerMatch = b.episodes ? b.withTriggerMatch / b.episodes : 0;
    b.pctViaSkill = b.episodes ? b.viaSkill / b.episodes : 0;
  }
  return out;
}

/**
 * Вывести шаг router-procedure.md, которого реально достиг эпизод, из
 * НАБЛЮДАЕМЫХ признаков primary_rationale (хранимое поле `step` исторически —
 * жёсткая константа 1 в обоих episode-builder'ах, поэтому ему не доверяем).
 *
 * Стадии (берётся максимум достигнутой):
 *   1 — hard-floor checkpoint (всегда пройден),
 *   2 — классификация дала реальный класс (task_classification ≠ 'other'),
 *   3 — подобраны триггеры (triggers_matched непуст),
 *   4 — найдена каноническая цепочка (chain_ref непуст),
 *   5 — выбран и исполнен узел (node_chosen ≠ 'direct').
 *
 * @param {object|undefined} pr  primary_rationale
 * @returns {1|2|3|4|5}
 */
export function deriveRouterStep(pr) {
  if (!pr || typeof pr !== 'object') return 1;
  let step = 1;
  if (pr.task_classification && pr.task_classification !== 'other') step = 2;
  if (Array.isArray(pr.triggers_matched) && pr.triggers_matched.length > 0) step = Math.max(step, 3);
  const chain = pr.chain_ref;
  const hasChain = Array.isArray(chain) ? chain.length > 0 : Boolean(chain);
  if (hasChain) step = Math.max(step, 4);
  if (pr.node_chosen && pr.node_chosen !== 'direct') step = Math.max(step, 5);
  return step;
}

/**
 * Распределение по шагу роутера, ВЫВЕДЕННОМУ из наблюдаемых признаков
 * (deriveRouterStep) — а не из хранимого pr.step (он был константой 1).
 * suspicious=true если total >= 5 && >90% эпизодов выводятся в step 1
 * (Pravila §16.4 sanity-check — теперь это реальный сигнал «дисциплина
 * проваливается / признаки не пишутся», а не гарантированный артефакт).
 *
 * @param {object[]} episodes
 * @returns {{ distribution: { [step: string]: number }, total: number, suspicious: boolean }}
 */
export function routerStepReached(episodes) {
  const distribution = {};
  let total = 0;
  for (const e of valid(episodes)) {
    const key = String(deriveRouterStep(e.primary_rationale));
    distribution[key] = (distribution[key] || 0) + 1;
    total += 1;
  }
  const stuckAt1 = (distribution['1'] || 0) / Math.max(total, 1);
  return { distribution, total, suspicious: total >= 5 && stuckAt1 > 0.9 };
}

/**
 * Доля эпизодов с непустыми applied boundaries, разбивка по path_type.
 *
 * @param {object[]} episodes
 * @returns {{ total: number, withBoundaries: number, rate: number, byPathType: object }}
 */
export function boundariesAppliedRate(episodes) {
  let total = 0, withBoundaries = 0;
  const byPathType = {};
  for (const e of valid(episodes)) {
    const pr = e.primary_rationale || {};
    const pt = e.path_type || 'null';
    const has = Array.isArray(pr.boundaries_applied) && pr.boundaries_applied.length > 0;
    total += 1;
    if (has) withBoundaries += 1;
    if (!byPathType[pt]) byPathType[pt] = { total: 0, withBoundaries: 0, rate: 0 };
    byPathType[pt].total += 1;
    if (has) byPathType[pt].withBoundaries += 1;
  }
  for (const b of Object.values(byPathType)) b.rate = b.total ? b.withBoundaries / b.total : 0;
  return { total, withBoundaries, rate: total ? withBoundaries / total : 0, byPathType };
}
