Space / dev /constraints /models.js
Harp404's picture
Round 2 — Constraint Awareness: Completion Signal
87bb7d6
Raw History Blame Contribute Delete
8.3 kB
/**
* Model artefact loader. dev/constraints/models.js
* =============================================================================
* Runs models that were trained offline (see ml/) as plain arithmetic.
*
* THE CONTRACT
*
* Training happens on a GPU, days before, in Python. What ships is a JSON
* artefact of a few hundred numbers. This file walks those numbers. There is
* no PyTorch in the request path, no Python service to keep alive, no model
* download at demo time, and nothing that can be slow or absent when it
* matters.
*
* THE OTHER HALF OF THE CONTRACT — and this is the part that matters
*
* A missing model is NOT a reason to guess. Every consumer of this module
* must treat `null` as UNEVALUATED, which propagates to UNRESOLVED. The rules
* are written that way already; this file simply never pretends.
*
* Concretely: no model here ever returns a default, a fallback, or a
* "reasonable estimate". It returns a number or it returns null.
*
* VALIDATION
*
* An artefact whose feature list we do not recognise is REFUSED rather than
* partially applied. A model silently fed the wrong columns is worse than no
* model at all, because it is confidently wrong.
*
* Zero dependencies.
* =============================================================================
*/
'use strict';
const fs = require('fs');
const path = require('path');
const MODEL_DIR = path.join(__dirname, '..', 'cache', 'models');
const registry = new Map();
/** Feature names each artefact is allowed to reference — the anti-mismatch check. */
const KNOWN_FEATURES = new Set([
'time_to_tca', 'miss_distance', 'relative_speed',
'relative_position_r', 'relative_position_t', 'relative_position_n',
't_sigma_r', 't_sigma_t', 't_sigma_n',
'c_sigma_r', 'c_sigma_t', 'c_sigma_n',
'max_risk_estimate', 'max_risk_scaling', 'mahalanobis_distance',
'c_object_type', 'risk',
// maneuver detector / classifier
'mean_motion', 'eccentricity', 'inclination', 'bstar', 'rcs_class',
'delta_mean_motion', 'delta_bstar', 'apogee_km', 'perigee_km',
// density model
'kp', 'f107', 'altitude_km', 'latitude', 'local_solar_time', 'doy',
]);
/** Load every artefact present. Absent models are simply absent. */
function loadAll() {
registry.clear();
let found = 0, refused = 0;
if (!fs.existsSync(MODEL_DIR)) return { found, refused, models: [] };
for (const file of fs.readdirSync(MODEL_DIR)) {
if (!file.endsWith('.json')) continue;
try {
const art = JSON.parse(fs.readFileSync(path.join(MODEL_DIR, file), 'utf8'));
const check = validateArtefact(art);
if (!check.ok) {
console.log(`[models] REFUSED ${file}: ${check.reason}`);
refused++;
continue;
}
registry.set(art.name, art);
found++;
} catch (e) {
console.log(`[models] REFUSED ${file}: ${e.message}`);
refused++;
}
}
return {
found, refused,
models: [...registry.values()].map((m) => ({
name: m.name, kind: m.kind, features: m.features.length,
trained_on: m.training && m.training.dataset,
held_out: m.training && (m.training.held_out_mae ?? m.training.held_out_score),
feeds: m.feeds,
})),
};
}
function validateArtefact(art) {
if (!art || typeof art !== 'object') return { ok: false, reason: 'not an object' };
if (!art.name) return { ok: false, reason: 'no name' };
if (!Array.isArray(art.features)) return { ok: false, reason: 'no feature list' };
const unknown = art.features.filter((f) => !KNOWN_FEATURES.has(f));
if (unknown.length) {
return { ok: false, reason: `unrecognised features ${unknown.join(', ')} — refusing rather than feeding the model the wrong columns` };
}
if (art.kind === 'gradient_boosted_trees') {
if (!Array.isArray(art.trees) || !art.trees.length) return { ok: false, reason: 'no trees' };
if (typeof art.init !== 'number') return { ok: false, reason: 'no init value' };
}
if (art.kind === 'random_forest_classifier') {
if (!Array.isArray(art.trees) || !art.trees.length) return { ok: false, reason: 'no trees' };
if (!Array.isArray(art.classes) || !art.classes.length) return { ok: false, reason: 'no class list' };
}
return { ok: true };
}
const get = (name) => registry.get(name) || null;
const has = (name) => registry.has(name);
// ---------------------------------------------------------------------------
// Inference — walking exported decision trees
// ---------------------------------------------------------------------------
function walkTree(tree, x) {
let node = 0;
// -2 is scikit-learn's leaf marker.
while (tree.feature[node] !== -2) {
node = x[tree.feature[node]] <= tree.threshold[node] ? tree.left[node] : tree.right[node];
}
return tree.value[node];
}
/**
* Score a feature object against a loaded model.
* Returns null when the model is absent OR any required feature is missing.
* Never substitutes a default — an incomplete input is an unknown.
*/
function walkTreeVec(tree, x) {
let node = 0;
while (tree.feature[node] !== -2) {
node = x[tree.feature[node]] <= tree.threshold[node] ? tree.left[node] : tree.right[node];
}
return tree.value[node];
}
function predict(name, featureObj) {
const art = get(name);
if (!art) return null;
const x = new Array(art.features.length);
for (let i = 0; i < art.features.length; i++) {
const f = art.features[i];
let v = featureObj[f];
const map = art.categorical_maps && art.categorical_maps[f];
if (map) v = map[String(v)];
const n = Number(v);
// A missing feature makes the whole prediction unavailable. Imputing here
// would manufacture confidence out of absence, which is the one thing this
// system exists to refuse.
if (!Number.isFinite(n)) {
return { value: null, missing: f, reason: `feature "${f}" not available — prediction withheld` };
}
x[i] = n;
}
if (art.kind === 'gradient_boosted_trees') {
let acc = art.init;
for (const t of art.trees) acc += art.learning_rate * walkTree(t, x);
return { value: acc, model: art.name, held_out: art.training && art.training.held_out_mae };
}
if (art.kind === 'random_forest_classifier') {
// Average the per-class probability vectors across trees.
const nCls = art.classes.length;
const acc = new Array(nCls).fill(0);
for (const t of art.trees) {
const counts = walkTreeVec(t, x);
const total = counts.reduce((a, b) => a + b, 0) || 1;
for (let i = 0; i < nCls; i++) acc[i] += counts[i] / total;
}
let best = 0;
for (let i = 1; i < nCls; i++) if (acc[i] > acc[best]) best = i;
const confidence = acc[best] / art.trees.length;
return {
value: art.classes[best],
confidence: +confidence.toFixed(3),
probabilities: Object.fromEntries(art.classes.map((c, i) => [c, +(acc[i] / art.trees.length).toFixed(3)])),
model: art.name,
held_out: art.training && art.training.held_out_score,
};
}
if (art.kind === 'linear') {
let acc = art.intercept || 0;
for (let i = 0; i < x.length; i++) acc += (art.coefficients[i] || 0) * x[i];
return { value: acc, model: art.name, held_out: art.training && art.training.held_out_score };
}
return null;
}
/** What the gateway reports about its own ML layer — including what is absent. */
function status() {
const models = [...registry.values()];
return {
loaded: models.length,
models: models.map((m) => ({
name: m.name,
kind: m.kind,
feeds: m.feeds,
trained_on: m.training && m.training.dataset,
held_out: m.training && (m.training.held_out_mae ?? m.training.held_out_score),
baseline: m.training && (m.training.baseline_persistence_mae ?? m.training.baseline_median_mae),
honesty: m.honesty || [],
})),
contract: 'Models are trained offline and shipped as JSON coefficients; inference is plain arithmetic. A model that is absent, or whose inputs are incomplete, yields no prediction — the rule it feeds reports UNEVALUATED and the signal goes UNRESOLVED. No model ever supplies a rule verdict directly.',
absent_is_not_a_guess: true,
};
}
module.exports = { loadAll, get, has, predict, status, validateArtefact, MODEL_DIR };