BID · Console
Baseline · Intelligence · Decision
src/intelligence/library.ts 7,493 bytes · typescript
/**
 * Methodology library — runtime loader + query API.
 *
 * Scans every `*.yaml` file under `src/intelligence/methodologies/`,
 * parses it into a structured Methodology object, and indexes the
 * results by id / type / domain / agent / status. Intelligence agents
 * query the loaded library through the helpers exported here; they
 * never touch the filesystem or the YAML directly. Adding or
 * deprecating a methodology means dropping/editing a YAML file —
 * agent code does not change (per Pillar 2 spec §SME Methodology
 * Library).
 *
 * The library is loaded lazily on first query and cached for the
 * process lifetime. `reloadLibrary()` clears the cache for tests.
 */

import { readdir, readFile, stat } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import YAML from 'yaml';

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const METHODOLOGIES_DIR = path.resolve(__dirname, 'methodologies');

/* -------------------------------------------------------------- *
 * Types
 * -------------------------------------------------------------- */

export type MethodologyType =
  | 'metric_definition'
  | 'comparison_method'
  | 'insight_framework'
  | 'normalization_rule';

export type MethodologyStatus = 'active' | 'deprecated' | 'under_review';

export type MethodologyAgent =
  | 'analytical_table'
  | 'performance_metrics'
  | 'comparisons_synthesis'
  | 'insight_synthesis';

export interface MethodologyAppliesTo {
  readonly agent: MethodologyAgent | string;
  readonly triggers: readonly string[];
}

export interface Methodology {
  readonly methodology_id: string;
  readonly name: string;
  readonly type: MethodologyType;
  readonly domain: string;
  readonly applies_to: MethodologyAppliesTo;
  readonly definition: Readonly<Record<string, unknown>>;
  readonly comparability_check?: Readonly<Record<string, unknown>>;
  readonly inputs: readonly unknown[];
  readonly outputs: readonly unknown[];
  readonly rationale: string;
  readonly declared_by: string;
  readonly declared_date: string;
  readonly last_reviewed?: string;
  readonly status: MethodologyStatus;
  /** Filesystem path the entry came from, for the audit trail. */
  readonly sourceFile: string;
}

export interface MethodologyQuery {
  readonly type?: MethodologyType;
  readonly domain?: string;
  readonly agent?: MethodologyAgent | string;
  /** Free-text triggers; matched as case-insensitive substring against
   *  each entry's `applies_to.triggers` lines. */
  readonly triggers?: readonly string[];
  /** Default 'active' only — pass 'any' to include deprecated. */
  readonly statusFilter?: MethodologyStatus | 'any';
}

/* -------------------------------------------------------------- *
 * Loader (lazy + cached)
 * -------------------------------------------------------------- */

let cache: readonly Methodology[] | null = null;
let cachePromise: Promise<readonly Methodology[]> | null = null;

export async function loadLibrary(): Promise<readonly Methodology[]> {
  if (cache) return cache;
  if (cachePromise) return cachePromise;
  cachePromise = (async () => {
    const files = await collectYamlFiles(METHODOLOGIES_DIR);
    const out: Methodology[] = [];
    for (const file of files) {
      try {
        const raw = await readFile(file, 'utf8');
        const parsed = YAML.parse(raw) as Partial<Methodology> | null;
        if (!parsed || typeof parsed !== 'object') continue;
        const m = normalize(parsed, file);
        if (m) out.push(m);
      } catch (err) {
        // eslint-disable-next-line no-console
        console.warn(`[methodology-library] skipped ${path.relative(process.cwd(), file)}: ${err instanceof Error ? err.message : String(err)}`);
      }
    }
    cache = out;
    return out;
  })();
  return cachePromise;
}

export function reloadLibrary(): void {
  cache = null;
  cachePromise = null;
}

/* -------------------------------------------------------------- *
 * Query helpers
 * -------------------------------------------------------------- */

export async function findMethodologies(query: MethodologyQuery = {}): Promise<readonly Methodology[]> {
  const all = await loadLibrary();
  const statusFilter = query.statusFilter ?? 'active';
  return all.filter(m => {
    if (statusFilter !== 'any' && m.status !== statusFilter) return false;
    if (query.type && m.type !== query.type) return false;
    if (query.domain && m.domain !== query.domain && m.domain !== 'all') return false;
    if (query.agent && m.applies_to.agent !== query.agent) return false;
    if (query.triggers && query.triggers.length > 0) {
      const hay = m.applies_to.triggers.join(' \n ').toLowerCase();
      const allMatch = query.triggers.every(t => hay.includes(t.toLowerCase()));
      if (!allMatch) return false;
    }
    return true;
  });
}

export async function getMethodology(id: string): Promise<Methodology | null> {
  const all = await loadLibrary();
  return all.find(m => m.methodology_id === id) ?? null;
}

export async function listIds(): Promise<readonly string[]> {
  const all = await loadLibrary();
  return all.map(m => m.methodology_id).sort();
}

/* -------------------------------------------------------------- *
 * Internals
 * -------------------------------------------------------------- */

async function collectYamlFiles(dir: string): Promise<string[]> {
  const out: string[] = [];
  let entries: string[];
  try {
    entries = await readdir(dir);
  } catch {
    return out;
  }
  for (const name of entries) {
    const p = path.join(dir, name);
    let s;
    try {
      s = await stat(p);
    } catch {
      continue;
    }
    if (s.isDirectory()) {
      out.push(...(await collectYamlFiles(p)));
    } else if (s.isFile() && /\.(yaml|yml)$/i.test(name)) {
      out.push(p);
    }
  }
  return out.sort();
}

function normalize(raw: Partial<Methodology>, sourceFile: string): Methodology | null {
  const id = typeof raw.methodology_id === 'string' ? raw.methodology_id : '';
  if (!id) return null;
  const type = (raw.type as MethodologyType) ?? 'metric_definition';
  const status = (raw.status as MethodologyStatus) ?? 'active';
  const applies = raw.applies_to ?? { agent: '', triggers: [] };
  const triggers = Array.isArray(applies.triggers) ? applies.triggers.map(t => String(t)) : [];
  return {
    methodology_id: id,
    name: typeof raw.name === 'string' ? raw.name : id,
    type,
    domain: typeof raw.domain === 'string' ? raw.domain : 'all',
    applies_to: {
      agent: typeof applies.agent === 'string' ? applies.agent : '',
      triggers,
    },
    definition: (raw.definition && typeof raw.definition === 'object'
      ? raw.definition
      : {}) as Record<string, unknown>,
    comparability_check:
      raw.comparability_check && typeof raw.comparability_check === 'object'
        ? (raw.comparability_check as Record<string, unknown>)
        : undefined,
    inputs: Array.isArray(raw.inputs) ? raw.inputs : [],
    outputs: Array.isArray(raw.outputs) ? raw.outputs : [],
    rationale: typeof raw.rationale === 'string' ? raw.rationale : '',
    declared_by: typeof raw.declared_by === 'string' ? raw.declared_by : '',
    declared_date: typeof raw.declared_date === 'string' ? raw.declared_date : '',
    last_reviewed: typeof raw.last_reviewed === 'string' ? raw.last_reviewed : undefined,
    status,
    sourceFile: path.relative(process.cwd(), sourceFile),
  };
}