← Files ForgeMindARCHIVED FILE
src/forge/product-loop.mjs
6.5 KB · Oct 2, 2026 · 00:31 UTC
import { ForgeMindError } from '../errors.mjs';
import { appendFlightEvent } from './flight.mjs';
import { stableId, verifyRecord } from './integrity.mjs';
import { saveForgeRecord } from './store.mjs';
const TRANSITIONS = {
signal: 'form-hypothesis',
hypothesis: 'approve-experiment',
experiment: 'record-delivery',
delivery: 'measure',
};
export async function createProductLoop({ workspace, input, now = new Date() }) {
validateLoopInput(input);
const loopId = stableId('product_loop', input);
const record = {
schemaVersion: 1,
protocol: 'forgemind-autonomous-product-loop-v1',
id: revisionId(loopId, 1),
loopId,
revision: 1,
parentDigest: null,
createdAt: now.toISOString(),
updatedAt: now.toISOString(),
name: input.name,
state: 'signal',
decision: null,
decisionRationale: null,
hypothesis: input.hypothesis,
successMetric: normalizeMetric(input.successMetric),
guardrails: input.guardrails.map(normalizeMetric),
measurements: null,
references: { signalRefs: [...new Set(input.signalRefs)].sort(), trustContractId: null, experimentId: null, proofId: null, attestationId: null },
history: [{ revision: 1, state: 'signal', event: 'created', timestamp: now.toISOString() }],
};
const saved = await saveForgeRecord({ workspace, area: 'loops', record });
await appendFlightEvent({ workspace, event: { capability: 'loop', action: 'create', subject: loopId, status: 'signal', revision: 1 }, now });
return { schemaVersion: 1, status: 'created', record: saved.record, evidencePath: saved.path, errors: [] };
}
export async function advanceProductLoop({ workspace, loop, event, now = new Date() }) {
if (verifyRecord(loop).status !== 'valid') throw new ForgeMindError('FM_LOOP_INVALID', 'Product loop record is missing or tampered.');
const expected = TRANSITIONS[loop.state];
if (!expected || event?.type !== expected) throw new ForgeMindError('FM_LOOP_TRANSITION_INVALID', `State ${loop.state} requires event ${expected ?? 'none'}; received ${event?.type ?? 'missing'}.`);
const next = structuredClone(loop);
delete next.digest;
next.revision += 1;
next.id = revisionId(loop.loopId, next.revision);
next.parentDigest = loop.digest.value;
next.updatedAt = now.toISOString();
if (event.type === 'form-hypothesis') {
if (!String(event.hypothesis ?? '').trim()) throw new ForgeMindError('FM_LOOP_EVIDENCE_REQUIRED', 'Hypothesis text is required.');
next.hypothesis = event.hypothesis;
next.state = 'hypothesis';
} else if (event.type === 'approve-experiment') {
if (!event.experiment?.id || !event.experiment?.trustContractId) throw new ForgeMindError('FM_LOOP_EVIDENCE_REQUIRED', 'Experiment ID and trust contract ID are required.');
next.references.experimentId = event.experiment.id;
next.references.trustContractId = event.experiment.trustContractId;
next.state = 'experiment';
} else if (event.type === 'record-delivery') {
if (!event.delivery?.proofId || !event.delivery?.attestationId) throw new ForgeMindError('FM_LOOP_EVIDENCE_REQUIRED', 'Delivery proof and trusted attestation IDs are required.');
next.references.proofId = event.delivery.proofId;
next.references.attestationId = event.delivery.attestationId;
next.state = 'delivery';
} else {
if (!event.metrics || typeof event.metrics !== 'object') throw new ForgeMindError('FM_LOOP_EVIDENCE_REQUIRED', 'Measured metrics are required.');
const guardrailFailures = next.guardrails.filter((metric) => !metricPasses(metric, event.metrics[metric.id])).map((metric) => metric.id);
const success = metricPasses(next.successMetric, event.metrics[next.successMetric.id]);
next.measurements = Object.fromEntries(Object.entries(event.metrics).map(([key, value]) => [key, Number(value)]));
if (guardrailFailures.length) {
next.state = 'rollback';
next.decision = 'rollback';
next.decisionRationale = `Rollback required because guardrail metric(s) failed: ${guardrailFailures.join(', ')}.`;
} else if (success) {
next.state = 'scale';
next.decision = 'scale';
next.decisionRationale = 'Scale because the declared success metric passed and every guardrail held.';
} else {
next.state = 'iterate';
next.decision = 'iterate';
next.decisionRationale = 'Iterate because guardrails held but the declared success metric is not proven.';
}
}
next.history.push({ revision: next.revision, state: next.state, event: event.type, timestamp: now.toISOString() });
const saved = await saveForgeRecord({ workspace, area: 'loops', record: next });
await appendFlightEvent({ workspace, event: { capability: 'loop', action: event.type, subject: loop.loopId, status: next.state, revision: next.revision }, now });
return { schemaVersion: 1, status: 'advanced', record: saved.record, evidencePath: saved.path, errors: [] };
}
function revisionId(loopId, revision) { return `${loopId}_r${String(revision).padStart(4, '0')}`; }
function normalizeMetric(metric) { return { id: metric.id, direction: metric.direction, target: Number(metric.target), baseline: metric.baseline === undefined ? null : Number(metric.baseline) }; }
function metricPasses(metric, rawActual) {
const actual = Number(rawActual);
if (!Number.isFinite(actual)) return false;
if (metric.direction === 'max') return actual <= metric.target;
if (metric.direction === 'min') return actual >= metric.target;
if (!Number.isFinite(metric.baseline)) return false;
if (metric.direction === 'decrease-by-percent') return actual <= metric.baseline * (1 - metric.target / 100);
if (metric.direction === 'increase-by-percent') return actual >= metric.baseline * (1 + metric.target / 100);
return false;
}
function validateLoopInput(input) {
for (const field of ['name', 'hypothesis']) if (!String(input?.[field] ?? '').trim()) throw new ForgeMindError('FM_LOOP_INPUT_INVALID', `Product loop field is required: ${field}`);
if (!Array.isArray(input.signalRefs) || input.signalRefs.length === 0) throw new ForgeMindError('FM_LOOP_INPUT_INVALID', 'Product loop requires signal references.');
if (!input.successMetric?.id || !input.successMetric?.direction || !Number.isFinite(Number(input.successMetric.target))) throw new ForgeMindError('FM_LOOP_INPUT_INVALID', 'Product loop success metric is invalid.');
if (!Array.isArray(input.guardrails) || input.guardrails.length === 0) throw new ForgeMindError('FM_LOOP_INPUT_INVALID', 'Product loop requires at least one guardrail.');
}
SHA-256: fde95828421261384365f701ce2d73126ba53f34360786301a32e0bf501b8230