@winkjs/composer 0.4.2 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,111 @@
1
+ // src/tools/stream-prep/stamp-period.js
2
+
3
+ /**
4
+ * @fileoverview stampPeriod — a stream-preparation utility for a source's
5
+ * `transform` option. Stamps each message with a monotonic integer key naming
6
+ * the calendar period — the local day, or the shift — its timestamp falls in.
7
+ * In place, with no per-message allocation.
8
+ *
9
+ * Why a key and not a "period rolled" flag: the roll (the first message of a
10
+ * new period, per asset) needs per-asset state, and per-asset state belongs
11
+ * to partitions. With the key on the message, a per-partition
12
+ * `stateChangeDetector` on it gives the per-asset roll edge in-flow, and a
13
+ * `controller` can flush windows on it. The utility stays stateless and
14
+ * stream-level; the flow owns the roll.
15
+ *
16
+ * The keys and their arithmetic (all integer, no Date objects):
17
+ * 'day' dayIndex = floor( (t + offset) / 86400000 ) — days since the
18
+ * epoch, counted in local wall-clock time (fixed offset).
19
+ * 'shift' shiftKey = (dayIndex * shiftsPerDay) + shiftIdx — unique and
20
+ * monotonic across days. A time earlier than the first boundary
21
+ * belongs to the PREVIOUS day's last shift (it wrapped past
22
+ * midnight), so the key never jumps backward at a boundary.
23
+ *
24
+ * Local time is a FIXED offset from UTC, same rule as labelShift: DST sites
25
+ * are out of scope; resolve local time upstream there.
26
+ *
27
+ * Family contract (ADR-025, stream-preparation utilities): configuration is
28
+ * captured once at init; the returned function mutates the message in place,
29
+ * creates no objects per message, and returns the same reference
30
+ * ( row -> row ). A non-finite timestamp writes null (no period known).
31
+ */
32
+
33
+ import { fieldNameOr, finiteNumberOr, validatedBoundaries } from './validate.js';
34
+
35
+ const MS_PER_MIN = 60000;
36
+ const MS_PER_DAY = 86400000;
37
+
38
+ /**
39
+ * Build an in-place period-key stamper.
40
+ *
41
+ * @param {Object} options
42
+ * @param {string} options.period - 'day' or 'shift'. Required.
43
+ * @param {number[]} [options.boundariesMin] - shift start minutes-of-day,
44
+ * strictly ascending, each in [0, 1440). Required for 'shift';
45
+ * not allowed for 'day'.
46
+ * @param {number} [options.offsetMinutes=0] - minutes to add to epoch-UTC to
47
+ * reach local wall-clock time (IST = 330).
48
+ * @param {string} [options.field='timestamp'] - source field holding epoch ms.
49
+ * @param {string} [options.target] - field to write the key to. Defaults to
50
+ * 'dayKey' or 'shiftKey' by period.
51
+ * @returns {function( Object ): Object} transform( msg ) -> msg, mutated in
52
+ * place. A non-finite timestamp writes null.
53
+ */
54
+ const stampPeriod = function ( options ) {
55
+ const opts = options || {};
56
+ const period = opts.period;
57
+ if ( ( period !== 'day' ) && ( period !== 'shift' ) ) {
58
+ throw new Error( 'winkComposer/stampPeriod: period must be day or shift.' );
59
+ }
60
+ const field = fieldNameOr( opts.field, 'timestamp', 'stampPeriod', 'field' );
61
+ const defaultTarget = ( period === 'day' ) ? 'dayKey' : 'shiftKey';
62
+ const target = fieldNameOr( opts.target, defaultTarget, 'stampPeriod', 'target' );
63
+ const offsetMinutes = finiteNumberOr( opts.offsetMinutes, 0, 'stampPeriod', 'offsetMinutes' );
64
+ const offsetMs = offsetMinutes * MS_PER_MIN;
65
+
66
+ if ( period === 'day' ) {
67
+ if ( opts.boundariesMin !== undefined ) {
68
+ throw new Error( 'winkComposer/stampPeriod: boundariesMin applies to shift only.' );
69
+ }
70
+ return function ( msg ) {
71
+ const t = msg[ field ];
72
+ msg[ target ] = Number.isFinite( t ) ? Math.floor( ( t + offsetMs ) / MS_PER_DAY ) : null;
73
+ return msg;
74
+ };
75
+ }
76
+
77
+ // period === 'shift' — the schedule is validated exactly as labelShift's.
78
+ const bounds = validatedBoundaries( opts.boundariesMin, 'stampPeriod' );
79
+ const shiftsPerDay = bounds.length;
80
+
81
+ return function ( msg ) {
82
+ const t = msg[ field ];
83
+ if ( !Number.isFinite( t ) ) {
84
+ msg[ target ] = null;
85
+ return msg;
86
+ }
87
+ const local = t + offsetMs;
88
+ const dayIndex = Math.floor( local / MS_PER_DAY );
89
+ let intoDay = local % MS_PER_DAY;
90
+ if ( intoDay < 0 ) {
91
+ intoDay += MS_PER_DAY;
92
+ }
93
+ const minute = Math.floor( intoDay / MS_PER_MIN );
94
+ if ( minute < bounds[ 0 ] ) {
95
+ // Wrapped past midnight: previous day's last shift, keeping the key monotonic.
96
+ msg[ target ] = ( ( dayIndex - 1 ) * shiftsPerDay ) + ( shiftsPerDay - 1 );
97
+ return msg;
98
+ }
99
+ let idx = 0;
100
+ for ( let i = 0; i < shiftsPerDay; i += 1 ) {
101
+ if ( minute >= bounds[ i ] ) {
102
+ idx = i;
103
+ }
104
+ }
105
+ msg[ target ] = ( dayIndex * shiftsPerDay ) + idx;
106
+ return msg;
107
+ };
108
+ }; // stampPeriod()
109
+
110
+ export { stampPeriod };
111
+ export default stampPeriod;
@@ -0,0 +1,223 @@
1
+ // src/tools/stream-prep/track-activity.js
2
+
3
+ /**
4
+ * @fileoverview trackActivity — a stream-preparation utility for a source's
5
+ * `transform` option. It turns an intermittent activity signal into a
6
+ * sustained active/idle state and reports how long that state has held, in
7
+ * place, allocating nothing per message.
8
+ *
9
+ * Why it exists: many plant questions reduce to "has this thing shown
10
+ * activity recently, and for how long?" — is the conveyor line running (a
11
+ * pulse toggled lately), is a sensor dead or stuck (its value has not changed
12
+ * in a long time), is a machine idle (its meter has stopped advancing). These
13
+ * are one measurement read from two ends: track the time since the last
14
+ * activity; recent means active, stale means idle/dead. This utility is that
15
+ * one measurement.
16
+ *
17
+ * What counts as "activity": a watched field's value CHANGED since the
18
+ * previous message. With several fields, any one changing is activity.
19
+ * `epsilon` ignores a numeric change smaller than itself (consecutive-step
20
+ * jitter). A field with no value this message (null / undefined / NaN)
21
+ * contributes no activity and does not disturb the baseline.
22
+ *
23
+ * The state model: hold "active" for `windowSec` after the last activity.
24
+ * `active` starts false and turns true only once a change has actually been
25
+ * observed — a gate never claims "running" without evidence, and a signal
26
+ * that never changes reads as never-active (dead). Reported per message:
27
+ * active is it active now (a change within the window)
28
+ * activeFor ms the current active run has lasted (0 when idle)
29
+ * sinceActivity ms since the last activity (null before any; the "stale" end)
30
+ * activeStart epoch-ms clock reading when the current active run began. Unlike
31
+ * the others it HOLDS its value while idle, keeping the last run's
32
+ * start, so an event that ends after the run stopped can still be
33
+ * dated back to the run it belonged to (null before any activity).
34
+ *
35
+ * Scope: this is for a STREAM-LEVEL / shared signal (a line, a plant)
36
+ * computed once upstream of partitioning. Because it compares to the previous
37
+ * message, it is correct when the watched fields are the same across a tick's
38
+ * messages (as shared line pulses are) — the change is caught once on the
39
+ * tick boundary and every message of that tick gets the same reading.
40
+ * Per-asset activity (a different value per message) needs per-key state,
41
+ * which is out of scope here.
42
+ *
43
+ * Family contract (ADR-025): fields, window, and output names are captured at
44
+ * init; the previous-value store is one object built once. The returned
45
+ * function reads and writes the message in place, creates nothing per
46
+ * message, and returns the same message so it drops into a source `transform`
47
+ * slot ( row -> row ). Durations are clamped to >= 0; timestamps are assumed
48
+ * monotonic per stream.
49
+ */
50
+
51
+ import { fieldNameOr, finiteNumberOr } from './validate.js';
52
+
53
+ /** True when a value is "no value this message" — never counts as activity. */
54
+ const isNoValue = function ( v ) {
55
+ return ( v === null ) || ( v === undefined ) || ( ( typeof v === 'number' ) && Number.isNaN( v ) );
56
+ }; // isNoValue()
57
+
58
+ /**
59
+ * Whether a value differs from its predecessor. Numbers compare by magnitude
60
+ * against `epsilon` (jitter gate); anything else compares strictly.
61
+ */
62
+ const differs = function ( cur, prev, epsilon ) {
63
+ if ( ( typeof cur === 'number' ) && ( typeof prev === 'number' ) ) {
64
+ return Math.abs( cur - prev ) > epsilon;
65
+ }
66
+ return cur !== prev;
67
+ }; // differs()
68
+
69
+ const ALLOWED_WRITES = [ 'active', 'activeFor', 'sinceActivity', 'activeStart' ];
70
+
71
+ /**
72
+ * Resolve the `from` option to a private array of watched field names.
73
+ * Init-time only; throws on anything that is not a name or a non-empty
74
+ * array of names.
75
+ */
76
+ const resolveFields = function ( from ) {
77
+ if ( typeof from === 'string' ) {
78
+ if ( from === '' ) {
79
+ throw new Error( 'winkComposer/trackActivity: from must be a non-empty field name.' );
80
+ }
81
+ return [ from ];
82
+ }
83
+ if ( Array.isArray( from ) && ( from.length > 0 ) ) {
84
+ for ( let i = 0; i < from.length; i += 1 ) {
85
+ if ( ( typeof from[ i ] !== 'string' ) || ( from[ i ] === '' ) ) {
86
+ throw new Error( 'winkComposer/trackActivity: every from field must be a non-empty string.' );
87
+ }
88
+ }
89
+ return from.slice();
90
+ }
91
+ throw new Error( 'winkComposer/trackActivity: from must be a field name or a non-empty array of field names.' );
92
+ }; // resolveFields()
93
+
94
+ /**
95
+ * Resolve the `writes` option to the four output names (null = not stamped).
96
+ * Init-time only; rejects unknown keys and empty names.
97
+ */
98
+ const resolveWrites = function ( writes ) {
99
+ if ( ( typeof writes !== 'object' ) || ( writes === null ) || Array.isArray( writes ) ) {
100
+ throw new Error( 'winkComposer/trackActivity: writes must be an object naming at least one output.' );
101
+ }
102
+ const writeKeys = Object.keys( writes );
103
+ if ( writeKeys.length === 0 ) {
104
+ throw new Error( 'winkComposer/trackActivity: writes must name at least one of active, activeFor, sinceActivity, activeStart.' );
105
+ }
106
+ for ( let i = 0; i < writeKeys.length; i += 1 ) {
107
+ const key = writeKeys[ i ];
108
+ if ( !ALLOWED_WRITES.includes( key ) ) {
109
+ throw new Error( 'winkComposer/trackActivity: unknown write \'' + key + '\' (allowed: active, activeFor, sinceActivity, activeStart).' );
110
+ }
111
+ if ( ( typeof writes[ key ] !== 'string' ) || ( writes[ key ] === '' ) ) {
112
+ throw new Error( 'winkComposer/trackActivity: writes.' + key + ' must be a non-empty field name.' );
113
+ }
114
+ }
115
+ return {
116
+ active: ( writes.active === undefined ) ? null : writes.active,
117
+ activeFor: ( writes.activeFor === undefined ) ? null : writes.activeFor,
118
+ sinceActivity: ( writes.sinceActivity === undefined ) ? null : writes.sinceActivity,
119
+ activeStart: ( writes.activeStart === undefined ) ? null : writes.activeStart
120
+ };
121
+ }; // resolveWrites()
122
+
123
+ /**
124
+ * Build an in-place activity-state stamper.
125
+ *
126
+ * @param {Object} options
127
+ * @param {string|string[]} options.from - field(s) to watch for change. Required.
128
+ * @param {number} options.windowSec - hold "active" this long after the last
129
+ * activity (seconds). Required, positive.
130
+ * @param {number} [options.epsilon=0] - ignore a numeric change at or below this
131
+ * between consecutive messages (a noise gate; 0 = any change counts).
132
+ * @param {string} [options.timestampField='timestamp'] - epoch-ms clock field.
133
+ * @param {Object} options.writes - names the outputs to stamp; at least one of
134
+ * `active`, `activeFor`, `sinceActivity`, `activeStart`, each a non-empty field name.
135
+ * @returns {function( Object ): Object} transform( msg ) -> msg, mutated in place.
136
+ * A non-finite timestamp writes null to the configured outputs and does not
137
+ * advance the state.
138
+ */
139
+ const trackActivity = function ( options ) {
140
+ const opts = options || {};
141
+ const fields = resolveFields( opts.from );
142
+ const windowSec = finiteNumberOr( opts.windowSec, undefined, 'trackActivity', 'windowSec' );
143
+ if ( windowSec <= 0 ) {
144
+ throw new Error( 'winkComposer/trackActivity: windowSec must be a positive number.' );
145
+ }
146
+ const windowMs = windowSec * 1000;
147
+ const epsilon = finiteNumberOr( opts.epsilon, 0, 'trackActivity', 'epsilon' );
148
+ if ( epsilon < 0 ) {
149
+ throw new Error( 'winkComposer/trackActivity: epsilon must be a non-negative number.' );
150
+ }
151
+ const tsField = fieldNameOr( opts.timestampField, 'timestamp', 'trackActivity', 'timestampField' );
152
+ const w = resolveWrites( opts.writes );
153
+
154
+ // State captured at init — the hot path allocates nothing. prev is a private
155
+ // dictionary of last-seen values ( external field-name keys -> Object.create(null) ).
156
+ const n = fields.length;
157
+ const prev = Object.create( null );
158
+ let lastActivityTs = null;
159
+ let activeStartTs = null;
160
+ let wasActive = false;
161
+
162
+ return function ( msg ) {
163
+ const t = msg[ tsField ];
164
+ if ( !Number.isFinite( t ) ) {
165
+ if ( w.active !== null ) {
166
+ msg[ w.active ] = null;
167
+ }
168
+ if ( w.activeFor !== null ) {
169
+ msg[ w.activeFor ] = null;
170
+ }
171
+ if ( w.sinceActivity !== null ) {
172
+ msg[ w.sinceActivity ] = null;
173
+ }
174
+ if ( w.activeStart !== null ) {
175
+ msg[ w.activeStart ] = null;
176
+ }
177
+ return msg;
178
+ }
179
+
180
+ let activity = false;
181
+ for ( let i = 0; i < n; i += 1 ) {
182
+ const f = fields[ i ];
183
+ const cur = msg[ f ];
184
+ if ( !isNoValue( cur ) ) {
185
+ const p = prev[ f ];
186
+ if ( p === undefined ) {
187
+ prev[ f ] = cur; // first value for this field — seed, no change
188
+ } else {
189
+ if ( differs( cur, p, epsilon ) ) {
190
+ activity = true;
191
+ }
192
+ prev[ f ] = cur;
193
+ }
194
+ }
195
+ }
196
+
197
+ if ( activity ) {
198
+ lastActivityTs = t;
199
+ }
200
+ const active = ( lastActivityTs !== null ) && ( ( t - lastActivityTs ) <= windowMs );
201
+ if ( active && ( wasActive === false ) ) {
202
+ activeStartTs = t; // rising edge — the active run begins now
203
+ }
204
+ wasActive = active;
205
+
206
+ if ( w.active !== null ) {
207
+ msg[ w.active ] = active;
208
+ }
209
+ if ( w.activeFor !== null ) {
210
+ msg[ w.activeFor ] = active ? Math.max( 0, t - activeStartTs ) : 0;
211
+ }
212
+ if ( w.sinceActivity !== null ) {
213
+ msg[ w.sinceActivity ] = ( lastActivityTs === null ) ? null : Math.max( 0, t - lastActivityTs );
214
+ }
215
+ if ( w.activeStart !== null ) {
216
+ msg[ w.activeStart ] = activeStartTs;
217
+ }
218
+ return msg;
219
+ };
220
+ }; // trackActivity()
221
+
222
+ export { trackActivity };
223
+ export default trackActivity;
@@ -0,0 +1,76 @@
1
+ // src/tools/stream-prep/validate.js
2
+
3
+ /**
4
+ * @fileoverview Shared init-time validation helpers for the stream-preparation
5
+ * utilities. Nothing here runs on the hot path — every helper is called once,
6
+ * inside a factory, and throws immediately with the calling utility's name
7
+ * leading the message, so a bad config names its own site (fail-fast, the
8
+ * ADR-025 family contract; same error style as the training utilities).
9
+ */
10
+
11
+ const MINUTES_PER_DAY = 1440;
12
+
13
+ /**
14
+ * Resolve an optional field-name option: undefined takes the fallback; the
15
+ * result must be a non-empty string.
16
+ *
17
+ * @param {*} value - the option as given
18
+ * @param {string} fallback - default when the option is undefined
19
+ * @param {string} ctx - calling utility name, for the error message
20
+ * @param {string} name - option name, for the error message
21
+ * @returns {string} the validated field name
22
+ */
23
+ const fieldNameOr = function ( value, fallback, ctx, name ) {
24
+ const out = ( value === undefined ) ? fallback : value;
25
+ if ( ( typeof out !== 'string' ) || ( out === '' ) ) {
26
+ throw new Error( 'winkComposer/' + ctx + ': ' + name + ' must be a non-empty string.' );
27
+ }
28
+ return out;
29
+ }; // fieldNameOr()
30
+
31
+ /**
32
+ * Resolve an optional numeric option: undefined takes the fallback; the
33
+ * result must be a finite number.
34
+ *
35
+ * @param {*} value - the option as given
36
+ * @param {number} fallback - default when the option is undefined
37
+ * @param {string} ctx - calling utility name, for the error message
38
+ * @param {string} name - option name, for the error message
39
+ * @returns {number} the validated number
40
+ */
41
+ const finiteNumberOr = function ( value, fallback, ctx, name ) {
42
+ const out = ( value === undefined ) ? fallback : value;
43
+ if ( ( typeof out !== 'number' ) || !Number.isFinite( out ) ) {
44
+ throw new Error( 'winkComposer/' + ctx + ': ' + name + ' must be a finite number.' );
45
+ }
46
+ return out;
47
+ }; // finiteNumberOr()
48
+
49
+ /**
50
+ * Validate a shift schedule's boundary list: a non-empty array of strictly
51
+ * ascending minutes-of-day, each in [0, 1440). Returns a private copy so the
52
+ * caller's hot path cannot be disturbed by later mutation of the original.
53
+ *
54
+ * @param {*} boundariesMin - the option as given
55
+ * @param {string} ctx - calling utility name, for the error message
56
+ * @returns {number[]} a validated private copy
57
+ */
58
+ const validatedBoundaries = function ( boundariesMin, ctx ) {
59
+ if ( !Array.isArray( boundariesMin ) || ( boundariesMin.length === 0 ) ) {
60
+ throw new Error( 'winkComposer/' + ctx + ': boundariesMin must be a non-empty array.' );
61
+ }
62
+ let prev = -1;
63
+ for ( let i = 0; i < boundariesMin.length; i += 1 ) {
64
+ const b = boundariesMin[ i ];
65
+ if ( ( typeof b !== 'number' ) || !Number.isFinite( b ) || ( b < 0 ) || ( b >= MINUTES_PER_DAY ) ) {
66
+ throw new Error( 'winkComposer/' + ctx + ': each boundary must be a minute-of-day in [0, 1440).' );
67
+ }
68
+ if ( b <= prev ) {
69
+ throw new Error( 'winkComposer/' + ctx + ': boundariesMin must be strictly ascending.' );
70
+ }
71
+ prev = b;
72
+ }
73
+ return boundariesMin.slice();
74
+ }; // validatedBoundaries()
75
+
76
+ export { fieldNameOr, finiteNumberOr, validatedBoundaries };