BID · Console
Baseline · Intelligence · Decision
src/agents/baseline/normalization/storage.ts 4,516 bytes · typescript
/**
 * Normalization — learned-mappings storage (Std 10).
 *
 * Direct file I/O: agent owns its own persistence. No shared
 * "mapping-table" module. Atomic write (tmp + rename) so a crash
 * mid-write never leaves a partially-serialized JSON.
 *
 * Empty by default: the framework ships with no domain content.
 * Entries accumulate as the agent learns new mappings via LLM
 * judgment and the orchestrator persists them.
 */

import { promises as fs } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';

const __filename = fileURLToPath(import.meta.url);
/** data/<agent>/ at the package root. */
const STORAGE_DIR = path.resolve(
  path.dirname(__filename),
  '..', '..', '..', '..', 'data', 'baseline.normalization',
);

export type LearnedSource = 'seed' | 'human' | 'ai' | 'lookup';

export interface LearnedMapping {
  /** Normalized lookup key (lowercase, whitespace-collapsed). */
  readonly key: string;
  /** Original raw label before key normalization. */
  readonly rawLabel: string;
  /** Canonical target (metric key or entity id). */
  readonly canonical: string;
  /** Where the mapping came from. */
  readonly by: LearnedSource;
  /** ISO timestamp. */
  readonly at: string;
  /** 0-1 confidence assigned at learn time. */
  readonly confidence: number;
  /** Citation / rationale captured at learn time (Std 4 — auditability). */
  readonly citation?: string;
}

interface StorageFile {
  readonly version: 1;
  readonly entries: LearnedMapping[];
}

export function normalizeKey(s: string): string {
  return s.toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();
}

export class LearnedMappingStore {
  private readonly metricFile: string;
  private readonly entityFile: string;
  private metricEntries = new Map<string, LearnedMapping>();
  private entityEntries = new Map<string, LearnedMapping>();
  private newlyLearned: LearnedMapping[] = [];

  constructor(dir: string = STORAGE_DIR) {
    this.metricFile = path.join(dir, 'metric-mappings.json');
    this.entityFile = path.join(dir, 'entity-mappings.json');
  }

  /** Idempotent. Reads files if present; ignores missing. */
  async load(): Promise<void> {
    await Promise.all([
      this.loadFile(this.metricFile, this.metricEntries),
      this.loadFile(this.entityFile, this.entityEntries),
    ]);
  }

  private async loadFile(file: string, into: Map<string, LearnedMapping>): Promise<void> {
    try {
      const txt = await fs.readFile(file, 'utf8');
      const data = JSON.parse(txt) as StorageFile;
      if (data && Array.isArray(data.entries)) {
        for (const e of data.entries) into.set(e.key, e);
      }
    } catch (err) {
      if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
    }
  }

  lookupMetric(rawLabel: string): LearnedMapping | undefined {
    return this.metricEntries.get(normalizeKey(rawLabel));
  }
  lookupEntity(rawLabel: string): LearnedMapping | undefined {
    return this.entityEntries.get(normalizeKey(rawLabel));
  }

  /** Additive — never overwrites existing entries (Std 4). */
  learnMetric(rawLabel: string, canonical: string, by: LearnedSource, confidence: number, citation?: string): void {
    const key = normalizeKey(rawLabel);
    if (this.metricEntries.has(key)) return;
    const e: LearnedMapping = { key, rawLabel, canonical, by, at: new Date().toISOString(), confidence, citation };
    this.metricEntries.set(key, e);
    this.newlyLearned.push(e);
  }
  learnEntity(rawLabel: string, canonical: string, by: LearnedSource, confidence: number, citation?: string): void {
    const key = normalizeKey(rawLabel);
    if (this.entityEntries.has(key)) return;
    const e: LearnedMapping = { key, rawLabel, canonical, by, at: new Date().toISOString(), confidence, citation };
    this.entityEntries.set(key, e);
    this.newlyLearned.push(e);
  }

  drainLearned(): LearnedMapping[] {
    const out = this.newlyLearned;
    this.newlyLearned = [];
    return out;
  }

  async save(): Promise<void> {
    await Promise.all([
      this.saveFile(this.metricFile, [...this.metricEntries.values()]),
      this.saveFile(this.entityFile, [...this.entityEntries.values()]),
    ]);
  }

  private async saveFile(file: string, entries: LearnedMapping[]): Promise<void> {
    await fs.mkdir(path.dirname(file), { recursive: true });
    const tmp = file + '.tmp';
    const data: StorageFile = { version: 1, entries };
    await fs.writeFile(tmp, JSON.stringify(data, null, 2), 'utf8');
    await fs.rename(tmp, file);
  }
}