File size: 8,304 Bytes
87bb7d6 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 | /**
* 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 };
|