/**
* 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);
}
}