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 };