B3.1 + B3.2 + B3.3: ChildRouter fan-out + commandRegistry 'none' + UnitPolicy dual-shape

B3.1 ChildRouter per-listener fan-out (drop emit monkey-patch):
  Partial-filter subscriptions enumerate every concrete
  <type>.measured.<position> event name (cartesian product over
  the canonical POSITIONS list + 19 KNOWN_TYPES) and register a
  plain emitter.on() per combo. Multi-parent semantics are trivial:
  each ChildRouter's listeners are independent. Drop the wrap/unwrap
  bookkeeping in tearDown. ChildRouter.js 184→164 lines.

B3.2 commandRegistry 'none' + description:
  Add 'none' to payloadSchema.type — handler still fires; logs warn
  if msg.payload is non-empty (catches accidental passes). Add
  optional `description` field per descriptor; surfaced via .list()
  so wikiGen can render per-topic effect text.
  commandRegistry.js 157→164 lines. 23/23 tests pass.

B3.3 UnitPolicy dual-shape:
  policy.canonical/output/curve are now BOTH callable methods AND
  frozen property bags. policy.canonical('flow') === 'm3/s' and
  policy.canonical.flow === 'm3/s' both work. Property bags are
  frozen (assign/delete/redefine throw in strict). Drops the
  _unitView workaround in MGC + rotatingMachine specificClass.
  UnitPolicy.js 149→163 lines, 15/15 tests pass.

CONTRACTS.md §4 + §6 updated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
znetsixe
2026-05-11 17:13:15 +02:00
parent ff9aec8702
commit f11754635b
6 changed files with 255 additions and 88 deletions

View File

@@ -7,35 +7,65 @@
*
* See CONTRACTS.md §5. Built on top of `childRegistrationUtils`, which
* already canonicalises softwareType (e.g. rotatingmachine → machine).
*
* Wildcard / partial-filter subscriptions enumerate every concrete
* `<type>.<variant>.<position>` event name the filter matches and attach a
* plain `emitter.on(...)` per combination. No emit patching — multi-parent
* stacks compose cleanly because each parent owns its own listeners.
*/
const { POSITION_VALUES } = require('../constants/positions');
// Same alias map as childRegistrationUtils. Duplicated rather than imported
// because we need to canonicalise inputs to onRegister/onMeasurement/onPrediction
// at *declaration* time (before any child has registered), so that a domain
// can write `onRegister('rotatingmachine', ...)` or `onRegister('machine', ...)`
// interchangeably and have the dispatch match.
const SOFTWARE_TYPE_ALIASES = {
rotatingmachine: 'machine',
machinegroupcontrol: 'machinegroup',
};
// Canonical measurement-type set used to enumerate position-only and
// match-everything filters. Sourced from MeasurementContainer.measureMap
// plus the EVOLV-specific synthetic types the nodes routinely emit
// (level / volumePercent / efficiency / Ncog / netFlowRate). Keep in sync
// with MeasurementContainer if new types land there.
const KNOWN_TYPES = Object.freeze([
'flow',
'pressure',
'atmPressure',
'power',
'hydraulicPower',
'reactivePower',
'apparentPower',
'temperature',
'level',
'volume',
'volumePercent',
'length',
'mass',
'energy',
'reactiveEnergy',
'efficiency',
'Ncog',
'netFlowRate',
]);
function canonicalType(rawType) {
const t = String(rawType || '').toLowerCase();
return SOFTWARE_TYPE_ALIASES[t] || t;
}
function lowerPosition(p) {
return String(p).toLowerCase();
}
class ChildRouter {
constructor(domain) {
this.domain = domain;
this.logger = domain?.logger || null;
// Subscription tables, keyed by canonical softwareType.
this._registerSubs = new Map(); // softwareType -> Array<fn>
this._measurementSubs = new Map(); // softwareType -> Array<{filter, fn}>
this._predictionSubs = new Map(); // softwareType -> Array<{filter, fn}>
// Track every emitter listener we attach so tearDown can remove them.
this._attached = [];
// Every plain emitter listener we attach, so tearDown can remove them.
this._listeners = [];
}
// ── declaration API ────────────────────────────────────────────────
@@ -60,7 +90,6 @@ class ChildRouter {
_addEventSub(table, softwareType, filter, fn, label) {
if (typeof filter === 'function' && fn === undefined) {
// Allow `onMeasurement(type, fn)` shorthand — no filter.
fn = filter;
filter = {};
}
@@ -75,10 +104,6 @@ class ChildRouter {
// ── dispatch ──────────────────────────────────────────────────────
/**
* Called by the domain's registerChild(). Runs onRegister handlers, then
* attaches measurement/prediction listeners on the child's emitter.
*/
dispatchRegister(child, softwareType) {
const key = canonicalType(softwareType);
@@ -98,51 +123,24 @@ class ChildRouter {
_attachVariantListeners(child, key, emitter, variant, table) {
const subs = table.get(key) || [];
for (const { filter, fn } of subs) {
// Build the set of (type, position) tuples this sub matches. If a filter
// omits one or both of {type, position}, we can't pre-enumerate the event
// names — fall back to a wildcard listener via `emit`-time matching.
if (filter.type && filter.position) {
const eventName = `${filter.type}.${variant}.${String(filter.position).toLowerCase()}`;
this._attach(emitter, eventName, (data) => this._invoke(fn, data, child, variant));
continue;
}
const types = filter.type ? [filter.type] : KNOWN_TYPES;
const positions = filter.position ? [lowerPosition(filter.position)] : POSITION_VALUES.map(lowerPosition);
const handlerLabel = variant === 'measured' ? 'onMeasurement' : 'onPrediction';
// Wildcard: subscribe to a generic catch-all by patching emitter.emit.
// EventEmitter has no built-in wildcard — install a one-off proxy listener
// that intercepts every emit on this emitter and filters by name.
const proxyKey = `__childRouter_proxy_${variant}__`;
if (!emitter[proxyKey]) {
const origEmit = emitter.emit.bind(emitter);
const proxies = [];
emitter[proxyKey] = proxies;
emitter.emit = (eventName, ...args) => {
const parts = String(eventName).split('.');
if (parts.length === 3 && parts[1] === variant) {
for (const p of proxies) p({ type: parts[0], position: parts[2], args });
}
return origEmit(eventName, ...args);
};
// Track the proxy install for tearDown to undo.
this._attached.push({ emitter, kind: 'proxy', variant, original: origEmit, proxyKey });
for (const type of types) {
for (const pos of positions) {
const eventName = `${type}.${variant}.${pos}`;
const listener = (data) => this._invoke(fn, data, child, handlerLabel);
emitter.on(eventName, listener);
this._listeners.push({ emitter, eventName, listener });
}
}
const proxyFn = ({ type, position, args }) => {
if (filter.type && type !== filter.type) return;
if (filter.position && position !== String(filter.position).toLowerCase()) return;
this._invoke(fn, args[0], child, variant);
};
emitter[proxyKey].push(proxyFn);
this._attached.push({ emitter, kind: 'proxyEntry', proxyKey, proxyFn });
}
}
_attach(emitter, eventName, listener) {
emitter.on(eventName, listener);
this._attached.push({ emitter, kind: 'listener', eventName, listener });
}
_invoke(fn, eventData, child, variant) {
_invoke(fn, eventData, child, handlerLabel) {
try { fn.call(this.domain, eventData, child); }
catch (err) { this._logHandlerError(`on${variant === 'measured' ? 'Measurement' : 'Prediction'}`, '', err); }
catch (err) { this._logHandlerError(handlerLabel, '', err); }
}
_logHandlerError(kind, key, err) {
@@ -154,31 +152,13 @@ class ChildRouter {
// ── teardown ──────────────────────────────────────────────────────
tearDown() {
// Two passes: drop concrete listeners + proxy entries first, then unwrap
// any proxies whose entry list is now empty. Order matters — restoring
// emit before clearing entries would leave dangling proxy state.
for (const rec of this._attached) {
if (rec.kind === 'listener') {
if (typeof rec.emitter.off === 'function') rec.emitter.off(rec.eventName, rec.listener);
else if (typeof rec.emitter.removeListener === 'function') rec.emitter.removeListener(rec.eventName, rec.listener);
} else if (rec.kind === 'proxyEntry') {
const proxies = rec.emitter[rec.proxyKey];
if (Array.isArray(proxies)) {
const idx = proxies.indexOf(rec.proxyFn);
if (idx >= 0) proxies.splice(idx, 1);
}
}
for (const { emitter, eventName, listener } of this._listeners) {
if (typeof emitter.off === 'function') emitter.off(eventName, listener);
else if (typeof emitter.removeListener === 'function') emitter.removeListener(eventName, listener);
}
for (const rec of this._attached) {
if (rec.kind !== 'proxy') continue;
const proxies = rec.emitter[rec.proxyKey];
if (!Array.isArray(proxies) || proxies.length === 0) {
rec.emitter.emit = rec.original;
delete rec.emitter[rec.proxyKey];
}
}
this._attached = [];
this._listeners = [];
}
}
module.exports = ChildRouter;
module.exports.KNOWN_TYPES = KNOWN_TYPES;