Space / dev /constraints /engine.js
Harp404's picture
Round 2 — Constraint Awareness: Completion Signal
87bb7d6
Raw History Blame Contribute Delete
16.8 kB
/**
* Constraint Awareness — the engine. dev/constraints/engine.js
* =============================================================================
* A generic evaluator for rulebooks. Given a set of rules and a context object,
* it returns ONE completion signal:
*
* COMPLETE every applicable rule evaluated and satisfied
* PARTIAL hard rules pass; a soft rule is violated or a waiver is on record
* BLOCKED a HARD rule is violated
* UNRESOLVED a rule could not be evaluated — the evidence does not exist yet
*
* Precedence: BLOCKED > UNRESOLVED > PARTIAL > COMPLETE.
* A hard violation is decisive even with unknowns still open: you already know
* enough to say no.
*
* TWO INVARIANTS CARRY THE WHOLE IDEA
*
* 1. UNRESOLVED IS NOT A PASS. Most systems show a green tick for "no
* violation found", silently conflating *checked and clean* with *never
* checked*. Here an unevaluated rule holds the signal open and names
* exactly what is missing.
*
* 2. YOU CANNOT WAIVE WHAT YOU NEVER MEASURED. A waiver only converts a rule
* that genuinely evaluated to VIOLATED, on a rule that is waivable at all.
* Applied to an unevaluated rule it is refused, with a reason.
*
* THIS FILE KNOWS NOTHING ABOUT ITS DOMAIN.
* It has never heard of a satellite. It takes a rulebook and a context and
* returns a signal. That is what makes the capability portable — the same engine
* gates an orbital maneuver, a software release, or anything else with limits,
* conditions and non-negotiable requirements. See ./rulebooks/.
*
* Zero dependencies. Pure functions. Deterministic: same input, same output,
* every time. See engine.test.js.
* =============================================================================
*/
'use strict';
// ---------------------------------------------------------------------------
// Vocabulary
// ---------------------------------------------------------------------------
/** The state of a single rule. */
const STATE = {
SATISFIED: 'SATISFIED', // evaluated, limit met
VIOLATED: 'VIOLATED', // evaluated, limit breached
UNEVALUATED: 'UNEVALUATED', // could not be evaluated — the input does not exist
NOT_APPLICABLE: 'NOT_APPLICABLE', // this rule does not govern this case
WAIVED: 'WAIVED', // breached, but a named party accepted the exception
};
/** The completion signal for the whole body of work. */
const SIGNAL = {
COMPLETE: 'COMPLETE',
PARTIAL: 'PARTIAL',
BLOCKED: 'BLOCKED',
UNRESOLVED: 'UNRESOLVED',
};
/** Rule classes. HARD blocks; SOFT degrades to PARTIAL but never blocks. */
const CLASS = { HARD: 'HARD', SOFT: 'SOFT' };
// ---------------------------------------------------------------------------
// Evaluator helpers, handed to every rule so rulebooks stay declarative.
// Each returns the {state, actual, limit, detail} shape the engine expects.
// ---------------------------------------------------------------------------
const R = {
/** Limit met. */
pass(detail, actual, limit) { return { state: STATE.SATISFIED, detail, actual, limit }; },
/** Limit breached. */
fail(detail, actual, limit) { return { state: STATE.VIOLATED, detail, actual, limit }; },
/**
* The input does not exist, so we do not know. NEVER use pass() for this —
* that is the exact mistake this whole module exists to prevent.
*/
unknown(detail, missing) { return { state: STATE.UNEVALUATED, detail, missing }; },
/** This rule does not govern this case. Excluded from the counts. */
na(detail, actual, limit) { return { state: STATE.NOT_APPLICABLE, detail, actual, limit }; },
/** Convenience: fail-safe numeric comparison that returns unknown() for a missing value. */
atMost(value, limit, fmt, unit) {
const v = numOrNull(value);
if (v === null) return R.unknown('value not available');
const f = fmt || ((x) => `${x}${unit ? ' ' + unit : ''}`);
const shown = { actual: f(v), limit: `<= ${f(limit)}` };
return v <= limit ? R.pass('within limit', shown.actual, shown.limit)
: R.fail('over limit', shown.actual, shown.limit);
},
atLeast(value, limit, fmt, unit) {
const v = numOrNull(value);
if (v === null) return R.unknown('value not available');
const f = fmt || ((x) => `${x}${unit ? ' ' + unit : ''}`);
const shown = { actual: f(v), limit: `>= ${f(limit)}` };
return v >= limit ? R.pass('within limit', shown.actual, shown.limit)
: R.fail('under limit', shown.actual, shown.limit);
},
};
function numOrNull(v) { const n = Number(v); return Number.isFinite(n) ? n : null; }
// ---------------------------------------------------------------------------
// Core evaluation
// ---------------------------------------------------------------------------
/**
* Evaluate a rulebook against a context.
*
* @param {object} opts
* @param {object} opts.rulebook { id, title, domain, rules: [...] }
* @param {object} opts.context whatever the rules need; opaque to the engine
* @param {Array} opts.waivers [{ rule_id, party, reason, ts }]
* @param {number} opts.now epoch ms — injectable so tests are deterministic
* @returns {object} the completion report
*/
function evaluate({ rulebook, context, waivers = [], now = Date.now() } = {}) {
if (!rulebook || !Array.isArray(rulebook.rules)) {
throw new TypeError('evaluate() needs a rulebook with a rules array');
}
const ctx = { ...(context || {}), now };
const waiverList = Array.isArray(waivers) ? waivers : [];
const rules = rulebook.rules.map((rule) => evaluateOne(rule, ctx, waiverList, now));
// NOT_APPLICABLE rules are excluded from every count — a rule that does not
// govern this case is neither work done nor work outstanding.
const applicable = rules.filter((r) => r.state !== STATE.NOT_APPLICABLE);
const blocking = rules.filter((r) => r.class === CLASS.HARD && r.state === STATE.VIOLATED);
const unevaluated = applicable.filter((r) => r.state === STATE.UNEVALUATED);
const advisory = rules.filter((r) => r.class === CLASS.SOFT && r.state === STATE.VIOLATED);
const waived = rules.filter((r) => r.state === STATE.WAIVED);
const satisfied = applicable.filter((r) => r.state === STATE.SATISFIED);
const counts = {
total: rules.length,
applicable: applicable.length,
satisfied: satisfied.length,
violated: blocking.length + advisory.length,
blocking: blocking.length,
advisory: advisory.length,
waived: waived.length,
unevaluated: unevaluated.length,
not_applicable: rules.length - applicable.length,
};
const { signal, headline } = deriveSignal({ counts, blocking, unevaluated, advisory, waived });
return {
rulebook: { id: rulebook.id, title: rulebook.title, domain: rulebook.domain },
signal,
headline,
// Authorisation is the actionable read of the signal. UNRESOLVED is NOT
// authorised — not knowing is not the same as being allowed.
authorised: signal === SIGNAL.COMPLETE || signal === SIGNAL.PARTIAL,
counts,
// Closed work over applicable work. Waived counts as closed (it was decided),
// unevaluated never does.
progress: counts.applicable
? Math.round(((counts.satisfied + counts.waived) / counts.applicable) * 100)
: 0,
blocking: blocking.map(brief),
unevaluated: unevaluated.map(brief),
advisory: advisory.map(brief),
waived: waived.map(brief),
next_actions: nextActions({ signal, blocking, unevaluated, advisory }),
// Earliest moment this signal degrades on its own if nobody acts.
deadline: earliestDeadline(rules, now),
// Evidence that, if obtained, would close currently-unevaluated rules.
// Consumed by the value-of-information planner.
evidence_needed: evidenceNeeded(unevaluated),
rules,
evaluated_at: new Date(now).toISOString(),
};
}
/** Evaluate one rule, apply waiver semantics, and never let a throw become a pass. */
function evaluateOne(rule, ctx, waiverList, now) {
const row = {
id: rule.id,
key: rule.key,
title: rule.title,
class: rule.class === CLASS.SOFT ? CLASS.SOFT : CLASS.HARD,
waivable: rule.waivable !== false,
authority: rule.authority || null,
requirement: rule.requirement || null,
rationale: rule.rationale || null,
state: STATE.UNEVALUATED,
actual: null,
limit: null,
detail: null,
missing: null,
waiver: null,
deadline: null,
resolved_by: rule.resolvedBy || null,
};
// An `applies` gate that itself cannot be decided must not silently exclude
// the rule — an undecidable applicability question is its own unknown.
if (typeof rule.applies === 'function') {
let applicable;
try { applicable = rule.applies(ctx); } catch (e) {
row.state = STATE.UNEVALUATED;
row.detail = 'cannot determine whether this rule applies: ' + e.message;
return row;
}
if (applicable === false) {
row.state = STATE.NOT_APPLICABLE;
row.detail = rule.notApplicableDetail || 'does not govern this case';
return row;
}
if (applicable == null) {
row.state = STATE.UNEVALUATED;
row.detail = 'cannot determine whether this rule applies';
return row;
}
}
let out;
try {
out = rule.evaluate(ctx);
} catch (e) {
// A rule that throws is an unknown, never a pass. Failing open here would
// defeat the entire purpose of the module.
out = { state: STATE.UNEVALUATED, detail: 'evaluator error: ' + e.message };
}
if (!out || !out.state) {
out = { state: STATE.UNEVALUATED, detail: 'evaluator returned no state' };
}
row.state = out.state;
row.actual = out.actual ?? null;
row.limit = out.limit ?? null;
row.detail = out.detail ?? null;
row.missing = out.missing ?? null;
// When this rule will degrade on its own, if it can.
if (typeof rule.deadline === 'function') {
try {
const d = rule.deadline(ctx);
if (Number.isFinite(d) && d > now) row.deadline = d;
} catch { /* a deadline we cannot compute is simply absent */ }
}
applyWaiver(row, waiverList.find((w) => w && w.rule_id === rule.id) || null);
return row;
}
/**
* Waiver semantics — the second invariant lives here.
* A waiver converts VIOLATED -> WAIVED, and only when the rule is waivable.
* It can never touch UNEVALUATED, and never touches a non-negotiable rule.
*/
function applyWaiver(row, waiver) {
if (!waiver) return;
if (!row.waivable) {
row.waiver_rejected = 'this rule is non-negotiable and cannot be waived';
return;
}
if (row.state === STATE.UNEVALUATED) {
row.waiver_rejected = 'cannot waive a rule that has not been evaluated';
return;
}
if (row.state !== STATE.VIOLATED) {
row.waiver_rejected = 'no violation to waive';
return;
}
row.state = STATE.WAIVED;
row.waiver = {
party: waiver.party || waiver.operator || 'unnamed',
reason: waiver.reason || 'no reason given',
ts: waiver.ts || null,
};
}
/** Precedence: BLOCKED > UNRESOLVED > PARTIAL > COMPLETE. */
function deriveSignal({ counts, blocking, unevaluated, advisory, waived }) {
if (blocking.length) {
const nonNegotiable = blocking.filter((r) => !r.waivable);
return {
signal: SIGNAL.BLOCKED,
headline: `${blocking.length} hard rule${blocking.length > 1 ? 's' : ''} violated — authorisation refused`
+ (nonNegotiable.length ? ` · ${nonNegotiable.length} non-negotiable, no override path` : ''),
};
}
if (unevaluated.length) {
return {
signal: SIGNAL.UNRESOLVED,
headline: `${counts.satisfied}/${counts.applicable} rules closed — ${unevaluated.length} cannot be evaluated yet`,
};
}
if (advisory.length || waived.length) {
const bits = [];
if (advisory.length) bits.push(`${advisory.length} advisory violation${advisory.length > 1 ? 's' : ''}`);
if (waived.length) bits.push(`${waived.length} waived`);
return {
signal: SIGNAL.PARTIAL,
headline: `all hard rules pass · ${bits.join(' · ')} — proceeding with exceptions on record`,
};
}
return {
signal: SIGNAL.COMPLETE,
headline: `all ${counts.applicable} applicable rules evaluated and satisfied — cleared`,
};
}
/** Ordered, specific list of what must happen to reach COMPLETE. */
function nextActions({ signal, blocking, unevaluated, advisory }) {
const out = [];
for (const r of blocking) {
out.push({
rule_id: r.id,
severity: 'BLOCKING',
action: r.waivable
? `Resolve ${r.id} (${r.title}) or file a named waiver — ${r.detail}`
: `Resolve ${r.id} (${r.title}) — NON-NEGOTIABLE, no waiver available: ${r.detail}`,
});
}
for (const r of unevaluated) {
out.push({
rule_id: r.id,
severity: 'UNKNOWN',
action: `Obtain the evidence for ${r.id} (${r.title}) — ${r.detail}`,
evidence: r.resolved_by || null,
});
}
for (const r of advisory) {
out.push({ rule_id: r.id, severity: 'ADVISORY', action: `Acknowledge ${r.id} (${r.title}) — ${r.detail}` });
}
if (!out.length && signal === SIGNAL.COMPLETE) {
out.push({ rule_id: null, severity: 'NONE', action: 'No outstanding constraint work.' });
}
return out;
}
/**
* The earliest moment the signal degrades with no action taken. Only rules that
* currently pass can have a live deadline — a rule already violated has nothing
* left to lose.
*/
function earliestDeadline(rules, now) {
let best = null;
for (const r of rules) {
if (!r.deadline) continue;
if (r.state !== STATE.SATISFIED && r.state !== STATE.WAIVED) continue;
if (best === null || r.deadline < best.at) {
best = { at: r.deadline, rule_id: r.id, title: r.title, class: r.class };
}
}
if (!best) return null;
return { ...best, at_iso: new Date(best.at).toISOString(), in_ms: best.at - now };
}
/** Distinct evidence items that would close currently-unevaluated rules. */
function evidenceNeeded(unevaluatedRules) {
const byKey = new Map();
for (const r of unevaluatedRules) {
for (const ev of r.resolved_by || []) {
const key = typeof ev === 'string' ? ev : ev.id;
if (!key) continue;
const entry = byKey.get(key) || {
evidence: key,
label: (typeof ev === 'object' && ev.label) || key,
cost: (typeof ev === 'object' && ev.cost) || null,
closes: [],
};
entry.closes.push(r.id);
byKey.set(key, entry);
}
}
return [...byKey.values()].sort((a, b) => b.closes.length - a.closes.length);
}
function brief(r) {
return {
id: r.id, key: r.key, title: r.title, class: r.class, waivable: r.waivable,
actual: r.actual, limit: r.limit, detail: r.detail,
authority: r.authority, resolved_by: r.resolved_by,
};
}
// ---------------------------------------------------------------------------
// Aggregate — "show whether THE RELATED WORK is complete, partial, blocked or
// unresolved" applies to the whole body of work, not just one item.
// ---------------------------------------------------------------------------
/**
* Roll many reports into one fleet-level completion signal.
* @param {Array} reports
*/
function rollup(reports) {
const list = (reports || []).filter(Boolean);
const by = { COMPLETE: 0, PARTIAL: 0, BLOCKED: 0, UNRESOLVED: 0 };
let applicable = 0, closed = 0;
for (const r of list) {
if (by[r.signal] !== undefined) by[r.signal]++;
applicable += r.counts.applicable;
closed += r.counts.satisfied + r.counts.waived;
}
// The overall signal takes the worst state present — an aggregate is only as
// resolved as its least-resolved member.
let signal = SIGNAL.COMPLETE;
if (by.BLOCKED) signal = SIGNAL.BLOCKED;
else if (by.UNRESOLVED) signal = SIGNAL.UNRESOLVED;
else if (by.PARTIAL) signal = SIGNAL.PARTIAL;
const soonest = list
.map((r) => r.deadline)
.filter(Boolean)
.sort((a, b) => a.at - b.at)[0] || null;
return {
signal,
items: list.length,
by,
rules_applicable: applicable,
rules_closed: closed,
progress: applicable ? Math.round((closed / applicable) * 100) : 0,
headline: list.length
? `${list.length} items · ${by.COMPLETE} complete · ${by.PARTIAL} partial · ${by.BLOCKED} blocked · ${by.UNRESOLVED} unresolved`
: 'no items',
next_deadline: soonest,
};
}
// ---------------------------------------------------------------------------
module.exports = {
evaluate,
rollup,
STATE,
SIGNAL,
CLASS,
R,
// exported for rulebooks and tests
_internal: { deriveSignal, applyWaiver, earliestDeadline, evidenceNeeded },
};