@mongrov/analytics 0.23.1 → 0.24.0

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.
Files changed (67) hide show
  1. package/dist/core/migrations.d.ts.map +1 -1
  2. package/dist/core/migrations.js +35 -0
  3. package/dist/core/migrations.js.map +1 -1
  4. package/dist/core/schemas.d.ts.map +1 -1
  5. package/dist/core/schemas.js +5 -2
  6. package/dist/core/schemas.js.map +1 -1
  7. package/dist/rules/compiler.d.ts.map +1 -1
  8. package/dist/rules/compiler.js +37 -16
  9. package/dist/rules/compiler.js.map +1 -1
  10. package/dist/sync/factory.d.ts +7 -0
  11. package/dist/sync/factory.d.ts.map +1 -1
  12. package/dist/sync/factory.js +31 -1
  13. package/dist/sync/factory.js.map +1 -1
  14. package/dist/sync/index.d.ts +2 -2
  15. package/dist/sync/index.d.ts.map +1 -1
  16. package/dist/sync/index.js +1 -1
  17. package/dist/sync/index.js.map +1 -1
  18. package/dist/sync/mapper/firmware.d.ts.map +1 -1
  19. package/dist/sync/mapper/firmware.js +7 -5
  20. package/dist/sync/mapper/firmware.js.map +1 -1
  21. package/dist/sync/mapper/schema.d.ts +29 -36
  22. package/dist/sync/mapper/schema.d.ts.map +1 -1
  23. package/dist/sync/mapper/schema.js +11 -12
  24. package/dist/sync/mapper/schema.js.map +1 -1
  25. package/dist/sync/mapper/sleep.d.ts +50 -43
  26. package/dist/sync/mapper/sleep.d.ts.map +1 -1
  27. package/dist/sync/mapper/sleep.js +99 -118
  28. package/dist/sync/mapper/sleep.js.map +1 -1
  29. package/dist/sync/mapper/types.d.ts +17 -18
  30. package/dist/sync/mapper/types.d.ts.map +1 -1
  31. package/dist/sync/sleep-correction/classify.d.ts +86 -0
  32. package/dist/sync/sleep-correction/classify.d.ts.map +1 -0
  33. package/dist/sync/sleep-correction/classify.js +247 -0
  34. package/dist/sync/sleep-correction/classify.js.map +1 -0
  35. package/dist/sync/sleep-correction/correct.d.ts +46 -0
  36. package/dist/sync/sleep-correction/correct.d.ts.map +1 -0
  37. package/dist/sync/sleep-correction/correct.js +60 -0
  38. package/dist/sync/sleep-correction/correct.js.map +1 -0
  39. package/dist/sync/sleep-correction/index.d.ts +8 -0
  40. package/dist/sync/sleep-correction/index.d.ts.map +1 -0
  41. package/dist/sync/sleep-correction/index.js +8 -0
  42. package/dist/sync/sleep-correction/index.js.map +1 -0
  43. package/dist/sync/sleep-correction/night.d.ts +26 -0
  44. package/dist/sync/sleep-correction/night.d.ts.map +1 -0
  45. package/dist/sync/sleep-correction/night.js +48 -0
  46. package/dist/sync/sleep-correction/night.js.map +1 -0
  47. package/dist/sync/sleep-correction/orchestrate.d.ts +50 -0
  48. package/dist/sync/sleep-correction/orchestrate.d.ts.map +1 -0
  49. package/dist/sync/sleep-correction/orchestrate.js +57 -0
  50. package/dist/sync/sleep-correction/orchestrate.js.map +1 -0
  51. package/dist/sync/sleep-correction/settle.d.ts +24 -0
  52. package/dist/sync/sleep-correction/settle.d.ts.map +1 -0
  53. package/dist/sync/sleep-correction/settle.js +59 -0
  54. package/dist/sync/sleep-correction/settle.js.map +1 -0
  55. package/dist/sync/sleep-correction/sql.d.ts +44 -0
  56. package/dist/sync/sleep-correction/sql.d.ts.map +1 -0
  57. package/dist/sync/sleep-correction/sql.js +329 -0
  58. package/dist/sync/sleep-correction/sql.js.map +1 -0
  59. package/dist/sync/sleep-correction/staging.d.ts +32 -0
  60. package/dist/sync/sleep-correction/staging.d.ts.map +1 -0
  61. package/dist/sync/sleep-correction/staging.js +51 -0
  62. package/dist/sync/sleep-correction/staging.js.map +1 -0
  63. package/dist/sync/sleep-derive.d.ts +55 -0
  64. package/dist/sync/sleep-derive.d.ts.map +1 -0
  65. package/dist/sync/sleep-derive.js +180 -0
  66. package/dist/sync/sleep-derive.js.map +1 -0
  67. package/package.json +4 -4
@@ -0,0 +1,329 @@
1
+ /**
2
+ * Sleep correction — Phase 2 SQL, ported from DataFusion to DuckDB.
3
+ *
4
+ * Source: ziva_app `sleepCorrectionLayer.ts` v3.1. The query SHAPE is kept as
5
+ * v3.1 wrote it — same CTEs, same self-joins, same UNION ALL offset generator
6
+ * — so a diff against the original reads as a list of dialect changes only:
7
+ *
8
+ * - `TO_CHAR(TO_TIMESTAMP(d), fmt)` → `strftime(make_timestamp(d * 1000000), fmt)`.
9
+ * make_timestamp is naive UTC; `to_timestamp()` would be TIMESTAMPTZ and
10
+ * render in the session zone, breaking Phase 3's UTC parse.
11
+ * - `APPROX_PERCENTILE_CONT` → `quantile_cont` (exact). Expect ~1-unit
12
+ * drift on the tier/p75/p90 scalars; never retune constants to hide it.
13
+ * - The unused nested-window `session_start` column is dropped (DuckDB
14
+ * rejects nested windows; the final `start` recomputes it anyway).
15
+ * - Confidence literals are CAST to DOUBLE — DuckDB types `0.90` as DECIMAL,
16
+ * DataFusion as Float64.
17
+ *
18
+ * Numbers are inlined, as v3.1 does: every value is computed by this module
19
+ * (epochs, tier, percentiles), never user input, and inlining sidesteps
20
+ * react-native-duckdb's untyped-param bind (zivaone_app#70). `num()` refuses
21
+ * anything non-finite.
22
+ *
23
+ * Relations are the v3.1 raw firmware shapes: sleep(date, quality, start,
24
+ * "unitLength"), heartrate(date, "singleHR"), activity(date, step), epochs in
25
+ * seconds. Production supplies staging views over the warehouse; the parity
26
+ * harness supplies tables loaded from captures.
27
+ */
28
+ import { CONF_ENVELOPE, CONF_FIRMWARE, CONF_GAP, EXTEND_AFTER, FETCH_AFTER, FETCH_BEFORE, HR_MAX, HR_MIN, LONG_GAP_HR_FRAC, LONG_GAP_MAX, LONG_GAP_MIN, STITCH_GAP_LONG, STITCH_GAP_MED, STITCH_GAP_SHORT, } from './classify';
29
+ export const DEFAULT_RELATIONS = {
30
+ sleep: 'stg_sleep',
31
+ heartrate: 'stg_heartrate',
32
+ activity: 'stg_activity',
33
+ };
34
+ const IDENT_RE = /^[a-z_][\w.]*$/i;
35
+ function rel(name) {
36
+ if (!IDENT_RE.test(name))
37
+ throw new Error(`sleep-correction: invalid relation name ${JSON.stringify(name)}`);
38
+ return name;
39
+ }
40
+ function num(v) {
41
+ if (!Number.isFinite(v))
42
+ throw new Error(`sleep-correction: non-finite SQL value ${String(v)}`);
43
+ return String(v);
44
+ }
45
+ /** Once per sync. `hr_interval_minutes` is 5, 10 or 30 (v3.1 CASE, NULL ⇒ 30). */
46
+ export function detectHrTierSql(r = DEFAULT_RELATIONS) {
47
+ return `
48
+ WITH hr_numbered AS (
49
+ SELECT date,
50
+ ROW_NUMBER() OVER (ORDER BY date) AS rn
51
+ FROM ${rel(r.heartrate)}
52
+ WHERE "singleHR" BETWEEN ${HR_MIN} AND ${HR_MAX}
53
+ ),
54
+ hr_intervals AS (
55
+ SELECT cur.date - COALESCE(prv.date, cur.date) AS gap_sec
56
+ FROM hr_numbered cur
57
+ LEFT JOIN hr_numbered prv ON prv.rn = cur.rn - 1
58
+ WHERE cur.date - COALESCE(prv.date, cur.date) BETWEEN 61 AND 3599
59
+ ),
60
+ median_gap AS (
61
+ SELECT quantile_cont(gap_sec, 0.5) AS med
62
+ FROM hr_intervals
63
+ )
64
+ SELECT
65
+ med AS median_interval_seconds,
66
+ CASE WHEN med <= 360 THEN 5
67
+ WHEN med <= 900 THEN 10
68
+ ELSE 30
69
+ END AS hr_interval_minutes
70
+ FROM median_gap;`;
71
+ }
72
+ /** Once per sync. Sleeping-HR p90 / p75 since `cutoffEpoch`; 120 when empty. */
73
+ export function globalBaselinesSql(cutoffEpoch, r = DEFAULT_RELATIONS) {
74
+ return `
75
+ WITH hr_sleep AS (
76
+ SELECT h."singleHR" AS hr
77
+ FROM ${rel(r.heartrate)} h
78
+ INNER JOIN ${rel(r.sleep)} s ON ABS(h.date - s.date) <= 30
79
+ WHERE h."singleHR" BETWEEN ${HR_MIN} AND ${HR_MAX}
80
+ AND CAST(s.quality AS INT) IN (1, 2, 3)
81
+ AND h.date >= ${num(cutoffEpoch)}
82
+ )
83
+ SELECT
84
+ COALESCE((SELECT quantile_cont(hr, 0.90) FROM hr_sleep), ${HR_MAX}) AS p90_global,
85
+ COALESCE((SELECT quantile_cont(hr, 0.75) FROM hr_sleep), ${HR_MAX}) AS p75_global;`;
86
+ }
87
+ /** Per night, before Phase 2: zero firmware sleep ⇒ skip the night entirely. */
88
+ export function hasSleepDataSql(windowStart, windowEnd, r = DEFAULT_RELATIONS) {
89
+ return `
90
+ SELECT COUNT(*) AS has_data
91
+ FROM ${rel(r.sleep)}
92
+ WHERE date >= ${num(windowStart)}
93
+ AND date < ${num(windowEnd)}
94
+ AND CAST(quality AS INT) IN (1, 2, 3, 5);`;
95
+ }
96
+ /** Phase 2 — envelope extension, gap recovery and session stitching. */
97
+ export function correctNightSql(windowStart, windowEnd, hrIntervalMin, globalP75, globalP90, r = DEFAULT_RELATIONS) {
98
+ const fetchStart = windowStart - FETCH_BEFORE;
99
+ const fetchEnd = windowEnd + FETCH_AFTER;
100
+ const offsets = Array.from({ length: hrIntervalMin }, (_, i) => `SELECT ${i} AS v`).join(' UNION ALL ');
101
+ const ws = num(windowStart);
102
+ const we = num(windowEnd);
103
+ return `
104
+ WITH sleep_raw AS (
105
+ SELECT
106
+ date,
107
+ CAST(quality AS INT) AS quality,
108
+ start AS fw_start,
109
+ CAST("unitLength" AS INT) AS unit_length
110
+ FROM ${rel(r.sleep)}
111
+ WHERE date >= ${ws}
112
+ AND date < ${we}
113
+ AND CAST(quality AS INT) IN (1, 2, 3, 5)
114
+ ),
115
+
116
+ fw_bounds AS (
117
+ SELECT
118
+ COALESCE(MIN(date), ${ws}) AS first_fw,
119
+ COALESCE(MAX(date), ${we}) AS last_fw
120
+ FROM sleep_raw
121
+ WHERE quality IN (1, 2, 3, 5)
122
+ ),
123
+
124
+ night_hr AS (
125
+ SELECT date, "singleHR" AS hr
126
+ FROM ${rel(r.heartrate)}
127
+ WHERE date >= ${num(fetchStart)}
128
+ AND date <= ${num(fetchEnd)}
129
+ AND "singleHR" BETWEEN ${HR_MIN} AND ${HR_MAX}
130
+ ),
131
+
132
+ effective AS (
133
+ SELECT
134
+ fw_bounds.first_fw AS eff_start,
135
+ fw_bounds.last_fw + ${EXTEND_AFTER} AS eff_end
136
+ FROM fw_bounds
137
+ ),
138
+
139
+ fw_trimmed AS (
140
+ SELECT s.date, s.quality, s.fw_start, s.unit_length,
141
+ CAST(${CONF_FIRMWARE} AS DOUBLE) AS confidence
142
+ FROM sleep_raw s
143
+ CROSS JOIN effective e
144
+ WHERE s.date >= e.eff_start
145
+ AND s.quality IN (1, 2, 3, 5)
146
+ ),
147
+
148
+ fw_has_rows AS (
149
+ SELECT CASE WHEN COUNT(*) > 0 THEN 1 ELSE 0 END AS has_rows
150
+ FROM sleep_raw
151
+ WHERE quality IN (1, 2, 3, 5)
152
+ ),
153
+
154
+ envelope_hr AS (
155
+ SELECT h.date AS hr_epoch
156
+ FROM night_hr h
157
+ CROSS JOIN effective e
158
+ CROSS JOIN fw_has_rows fhr
159
+ CROSS JOIN fw_bounds fb
160
+ WHERE fhr.has_rows = 1
161
+ AND h.date >= fb.first_fw
162
+ AND h.date <= e.eff_end
163
+ AND h.hr <= ${num(globalP90)}
164
+ AND NOT EXISTS (
165
+ SELECT 1 FROM sleep_raw fs
166
+ WHERE ABS(fs.date - h.date) <= 30
167
+ AND fs.quality IN (1, 2, 3, 5)
168
+ )
169
+ ),
170
+
171
+ offsets AS (${offsets}),
172
+
173
+ envelope_expanded AS (
174
+ SELECT DISTINCT
175
+ eh.hr_epoch + (o.v * 60) AS date,
176
+ 2 AS quality,
177
+ NULL AS fw_start,
178
+ 1 AS unit_length,
179
+ CAST(${CONF_ENVELOPE} AS DOUBLE) AS confidence
180
+ FROM envelope_hr eh
181
+ CROSS JOIN offsets o
182
+ CROSS JOIN effective e
183
+ WHERE eh.hr_epoch + (o.v * 60) >= e.eff_start
184
+ AND eh.hr_epoch + (o.v * 60) <= e.eff_end
185
+ ),
186
+
187
+ fw_sessions AS (
188
+ SELECT fw_start,
189
+ MIN(date) AS sess_start,
190
+ MAX(date) AS sess_end,
191
+ COUNT(*) AS sess_len
192
+ FROM sleep_raw
193
+ WHERE quality IN (1, 2, 3, 5)
194
+ GROUP BY fw_start
195
+ ),
196
+
197
+ fw_sess_numbered AS (
198
+ SELECT *,
199
+ ROW_NUMBER() OVER (ORDER BY sess_start) AS rn
200
+ FROM fw_sessions
201
+ ),
202
+
203
+ fw_gaps AS (
204
+ SELECT
205
+ cur.sess_end AS gap_start,
206
+ nxt.sess_start AS gap_end,
207
+ nxt.sess_start - cur.sess_end AS gap_sec
208
+ FROM fw_sess_numbered cur
209
+ LEFT JOIN fw_sess_numbered nxt ON nxt.rn = cur.rn + 1
210
+ WHERE nxt.sess_start IS NOT NULL
211
+ AND nxt.sess_start - cur.sess_end BETWEEN ${LONG_GAP_MIN} AND ${LONG_GAP_MAX}
212
+ ),
213
+
214
+ gap_hr_check AS (
215
+ SELECT
216
+ fg.gap_start, fg.gap_end,
217
+ CASE
218
+ WHEN (SELECT has_rows FROM fw_has_rows) = 0 THEN 0
219
+ WHEN COUNT(h.date) >= 3
220
+ AND CAST(SUM(CASE WHEN h.hr <= ${num(globalP75)} THEN 1 ELSE 0 END) AS DOUBLE)
221
+ / CAST(COUNT(h.date) AS DOUBLE) >= ${LONG_GAP_HR_FRAC}
222
+ THEN 1 ELSE 0
223
+ END AS qualifies
224
+ FROM fw_gaps fg
225
+ LEFT JOIN night_hr h
226
+ ON h.date > fg.gap_start
227
+ AND h.date < fg.gap_end
228
+ GROUP BY fg.gap_start, fg.gap_end
229
+ ),
230
+
231
+ gap_recovered AS (
232
+ SELECT DISTINCT
233
+ h.date + (o.v * 60) AS date,
234
+ 2 AS quality,
235
+ NULL AS fw_start,
236
+ 1 AS unit_length,
237
+ CAST(${CONF_GAP} AS DOUBLE) AS confidence
238
+ FROM gap_hr_check gc
239
+ INNER JOIN night_hr h
240
+ ON h.date > gc.gap_start
241
+ AND h.date < gc.gap_end
242
+ CROSS JOIN offsets o
243
+ WHERE gc.qualifies = 1
244
+ AND h.date + (o.v * 60) > gc.gap_start
245
+ AND h.date + (o.v * 60) < gc.gap_end
246
+ ),
247
+
248
+ combined_raw AS (
249
+ SELECT date, quality, fw_start, unit_length, confidence, 'firmware' AS source
250
+ FROM fw_trimmed
251
+ UNION ALL
252
+ SELECT date, quality, fw_start, unit_length, confidence, 'envelope' AS source
253
+ FROM envelope_expanded
254
+ UNION ALL
255
+ SELECT date, quality, fw_start, unit_length, confidence, 'gap' AS source
256
+ FROM gap_recovered
257
+ ),
258
+
259
+ deduped AS (
260
+ SELECT date, quality, fw_start, unit_length, confidence, source
261
+ FROM (
262
+ SELECT *,
263
+ ROW_NUMBER() OVER (
264
+ PARTITION BY date
265
+ ORDER BY CASE source WHEN 'firmware' THEN 0 ELSE 1 END
266
+ ) AS rn
267
+ FROM combined_raw
268
+ )
269
+ WHERE rn = 1
270
+ ),
271
+
272
+ deduped_numbered AS (
273
+ SELECT *,
274
+ ROW_NUMBER() OVER (ORDER BY date) AS rn
275
+ FROM deduped
276
+ ),
277
+
278
+ with_gaps AS (
279
+ SELECT
280
+ cur.date, cur.quality, cur.fw_start, cur.unit_length,
281
+ cur.confidence, cur.source, cur.rn,
282
+ cur.date - COALESCE(prv.date, cur.date) AS gap_to_prev,
283
+ COALESCE(prv.unit_length, 0) AS prev_unit_length,
284
+ COALESCE(prv.date, cur.date) AS prev_date
285
+ FROM deduped_numbered cur
286
+ LEFT JOIN deduped_numbered prv ON prv.rn = cur.rn - 1
287
+ ),
288
+
289
+ stitched AS (
290
+ SELECT
291
+ date, quality, source, confidence,
292
+ SUM(CASE
293
+ WHEN gap_to_prev <= ${STITCH_GAP_SHORT} THEN 0
294
+ WHEN prev_unit_length = 120
295
+ AND gap_to_prev <= ${STITCH_GAP_MED} THEN 0
296
+ WHEN gap_to_prev <= ${STITCH_GAP_MED} THEN 0
297
+ WHEN gap_to_prev <= ${STITCH_GAP_LONG} THEN 0
298
+ ELSE 1
299
+ END) OVER (ORDER BY rn ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS session_id
300
+ FROM with_gaps
301
+ )
302
+
303
+ SELECT
304
+ strftime(make_timestamp(date * 1000000), '%Y.%m.%d %H:%M:%S') AS date,
305
+ quality,
306
+ strftime(make_timestamp(MIN(date) OVER (
307
+ PARTITION BY session_id
308
+ ) * 1000000), '%Y.%m.%d %H:%M:%S') AS start,
309
+ 1 AS "unitLength",
310
+ source,
311
+ confidence,
312
+ session_id
313
+ FROM stitched
314
+ ORDER BY date;`;
315
+ }
316
+ /** Phase 3 input — HR + step samples for the Step 5.6 END refinement. */
317
+ export function morningVitalsSql(windowStart, windowEnd, r = DEFAULT_RELATIONS) {
318
+ return `
319
+ SELECT 'hr' AS kind, date AS epoch, "singleHR" AS value
320
+ FROM ${rel(r.heartrate)}
321
+ WHERE date >= ${num(windowStart)} AND date < ${num(windowEnd + FETCH_AFTER)}
322
+ AND "singleHR" BETWEEN ${HR_MIN} AND ${HR_MAX}
323
+ UNION ALL
324
+ SELECT 'step' AS kind, date AS epoch, step AS value
325
+ FROM ${rel(r.activity)}
326
+ WHERE date >= ${num(windowStart)} AND date < ${num(windowEnd + FETCH_AFTER)}
327
+ ORDER BY epoch;`;
328
+ }
329
+ //# sourceMappingURL=sql.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sql.js","sourceRoot":"","sources":["../../../src/sync/sleep-correction/sql.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Staging views: the warehouse, re-shaped into v3.1's raw firmware relations.
3
+ *
4
+ * The pipeline's SQL reads sleep(date, quality, start, "unitLength"),
5
+ * heartrate(date, "singleHR") and activity(date, step) with epoch-second
6
+ * dates — the shapes v3.1 was validated on. Rather than rewrite the SQL for
7
+ * the warehouse columns, these TEMP views rename back at the boundary, so the
8
+ * ported queries stay as close to v3.1 as the parity suite proved them.
9
+ *
10
+ * - sleep comes from the LOCAL `sleep_raw` (not viewed: collected-only, and
11
+ * the nights a batch touches are local). Catalog-qualified, because after
12
+ * an R2 attach an unqualified name resolves to the remote catalog.
13
+ * - HR and steps come from `v_heart_rate` / `v_activity`, so a 30-day
14
+ * baseline lookback sees remote history too.
15
+ * - One user and one device: the correction is per ring.
16
+ *
17
+ * `sleep_raw.quality` must hold the firmware's own codes (1 deep / 2 light /
18
+ * 3 rem / 5 awake). That is true only once the producer sends raw rows
19
+ * (sleep-correction tasks §3); until then these views are exercised by tests.
20
+ */
21
+ import type { SleepRelations } from './sql';
22
+ export interface StagingScope {
23
+ brand: string;
24
+ familyId: string;
25
+ userId: string;
26
+ deviceId: string;
27
+ /** Local DuckDB catalog holding `sleep_raw`. */
28
+ localCatalog?: string;
29
+ }
30
+ /** `CREATE OR REPLACE TEMP VIEW` statements for the three staging relations. */
31
+ export declare function stagingViewsSql(scope: StagingScope, r?: SleepRelations): string[];
32
+ //# sourceMappingURL=staging.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"staging.d.ts","sourceRoot":"","sources":["../../../src/sync/sleep-correction/staging.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAEH,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,OAAO,CAAA;AAI3C,MAAM,WAAW,YAAY;IAC3B,KAAK,EAAE,MAAM,CAAA;IACb,QAAQ,EAAE,MAAM,CAAA;IAChB,MAAM,EAAE,MAAM,CAAA;IACd,QAAQ,EAAE,MAAM,CAAA;IAChB,gDAAgD;IAChD,YAAY,CAAC,EAAE,MAAM,CAAA;CACtB;AASD,gFAAgF;AAChF,wBAAgB,eAAe,CAAC,KAAK,EAAE,YAAY,EAAE,CAAC,GAAE,cAAkC,GAAG,MAAM,EAAE,CAsBpG"}
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Staging views: the warehouse, re-shaped into v3.1's raw firmware relations.
3
+ *
4
+ * The pipeline's SQL reads sleep(date, quality, start, "unitLength"),
5
+ * heartrate(date, "singleHR") and activity(date, step) with epoch-second
6
+ * dates — the shapes v3.1 was validated on. Rather than rewrite the SQL for
7
+ * the warehouse columns, these TEMP views rename back at the boundary, so the
8
+ * ported queries stay as close to v3.1 as the parity suite proved them.
9
+ *
10
+ * - sleep comes from the LOCAL `sleep_raw` (not viewed: collected-only, and
11
+ * the nights a batch touches are local). Catalog-qualified, because after
12
+ * an R2 attach an unqualified name resolves to the remote catalog.
13
+ * - HR and steps come from `v_heart_rate` / `v_activity`, so a 30-day
14
+ * baseline lookback sees remote history too.
15
+ * - One user and one device: the correction is per ring.
16
+ *
17
+ * `sleep_raw.quality` must hold the firmware's own codes (1 deep / 2 light /
18
+ * 3 rem / 5 awake). That is true only once the producer sends raw rows
19
+ * (sleep-correction tasks §3); until then these views are exercised by tests.
20
+ */
21
+ import { DEFAULT_RELATIONS } from './sql';
22
+ const CATALOG_RE = /^[a-z_]\w*$/i;
23
+ const QUOTE_RE = /'/g;
24
+ function lit(s) {
25
+ return `'${s.replace(QUOTE_RE, '\'\'')}'`;
26
+ }
27
+ /** `CREATE OR REPLACE TEMP VIEW` statements for the three staging relations. */
28
+ export function stagingViewsSql(scope, r = DEFAULT_RELATIONS) {
29
+ const catalog = scope.localCatalog ?? 'memory';
30
+ if (!CATALOG_RE.test(catalog))
31
+ throw new Error(`sleep-correction: invalid catalog ${JSON.stringify(catalog)}`);
32
+ const tenant = `user_id = ${lit(scope.userId)} AND device_id = ${lit(scope.deviceId)}`;
33
+ return [
34
+ `CREATE OR REPLACE TEMP VIEW ${r.sleep} AS
35
+ SELECT CAST(epoch(ts) AS BIGINT) AS date,
36
+ quality,
37
+ CAST(epoch(ts_session_start) AS BIGINT) AS start,
38
+ unit_length AS "unitLength"
39
+ FROM ${catalog}.main.sleep_raw
40
+ WHERE brand = ${lit(scope.brand)} AND family_id = ${lit(scope.familyId)} AND ${tenant}`,
41
+ `CREATE OR REPLACE TEMP VIEW ${r.heartrate} AS
42
+ SELECT CAST(epoch(ts) AS BIGINT) AS date, bpm AS "singleHR"
43
+ FROM v_heart_rate
44
+ WHERE ${tenant}`,
45
+ `CREATE OR REPLACE TEMP VIEW ${r.activity} AS
46
+ SELECT CAST(epoch(ts) AS BIGINT) AS date, steps AS step
47
+ FROM v_activity
48
+ WHERE ${tenant}`,
49
+ ];
50
+ }
51
+ //# sourceMappingURL=staging.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"staging.js","sourceRoot":"","sources":["../../../src/sync/sleep-correction/staging.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAIH,OAAO,EAAE,iBAAiB,EAAE,MAAM,OAAO,CAAA;AAWzC,MAAM,UAAU,GAAG,cAAc,CAAA;AACjC,MAAM,QAAQ,GAAG,IAAI,CAAA;AAErB,SAAS,GAAG,CAAC,CAAS;IACpB,OAAO,IAAI,CAAC,CAAC,OAAO,CAAC,QAAQ,EAAE,MAAM,CAAC,GAAG,CAAA;AAC3C,CAAC;AAED,gFAAgF;AAChF,MAAM,UAAU,eAAe,CAAC,KAAmB,EAAE,IAAoB,iBAAiB;IACxF,MAAM,OAAO,GAAG,KAAK,CAAC,YAAY,IAAI,QAAQ,CAAA;IAC9C,IAAI,CAAC,UAAU,CAAC,IAAI,CAAC,OAAO,CAAC;QAC3B,MAAM,IAAI,KAAK,CAAC,qCAAqC,IAAI,CAAC,SAAS,CAAC,OAAO,CAAC,EAAE,CAAC,CAAA;IACjF,MAAM,MAAM,GAAG,aAAa,GAAG,CAAC,KAAK,CAAC,MAAM,CAAC,oBAAoB,GAAG,CAAC,KAAK,CAAC,QAAQ,CAAC,EAAE,CAAA;IACtF,OAAO;QACL,+BAA+B,CAAC,CAAC,KAAK;;;;;OAKnC,OAAO;gBACE,GAAG,CAAC,KAAK,CAAC,KAAK,CAAC,oBAAoB,GAAG,CAAC,KAAK,CAAC,QAAQ,CAAC,QAAQ,MAAM,EAAE;QACnF,+BAA+B,CAAC,CAAC,SAAS;;;QAGtC,MAAM,EAAE;QACZ,+BAA+B,CAAC,CAAC,QAAQ;;;QAGrC,MAAM,EAAE;KACb,CAAA;AACH,CAAC"}
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Derived sleep — the correction step every batch cycle runs before
3
+ * `batch:complete` (sleep-correction §3).
4
+ *
5
+ * `sleep_raw` is written like any sensor table. Sessions and stages are not:
6
+ * they are derived from it by the validated correction pipeline, night by
7
+ * night, and written back into the SAME batch, so a `context: 'asleep'` rule
8
+ * woken by `batch:complete` sees corrected sleep, never raw minutes.
9
+ *
10
+ * Raw rows can arrive by three flush paths (scheduled cycle, manual flush,
11
+ * size/age triggers), so the deriver does not hang off any one flush: it
12
+ * notes every enqueued `sleep_raw` range, and the next batch cycle corrects
13
+ * all pending nights after its flushes land.
14
+ *
15
+ * Night replace (principle 66 as amended): a corrected night's END moves as
16
+ * later data arrives, and the principle-25 id moves with it, so a night is
17
+ * DELETEd locally before its new rows are written. Nights that come back with
18
+ * no primary block are left alone — replacing a night with nothing on a
19
+ * partial sync would erase sleep the next sync restores.
20
+ *
21
+ * Local only. Rows already pushed to a remote catalog are not rewritten; that
22
+ * needs a server-side answer before cloud sync is switched on.
23
+ */
24
+ import type { HybridDuckDB } from '../core/engine';
25
+ import type { SensorBuffer } from './buffer';
26
+ import type { BatchFlusher, FlushReason } from './flusher';
27
+ import type { SchedulerLogger } from './scheduler';
28
+ export interface SleepDeriverDeps {
29
+ engine: HybridDuckDB;
30
+ buffer: SensorBuffer;
31
+ flusher: BatchFlusher;
32
+ /** The user's IANA zone (the factory's `resolveTimezone`). */
33
+ resolveTimezone: (userId: string) => Promise<string>;
34
+ /** Baseline lookback. v3.1 production: 30 days. */
35
+ lookbackDays?: number;
36
+ now?: () => number;
37
+ logger?: SchedulerLogger;
38
+ localCatalog?: string;
39
+ }
40
+ export interface SleepDeriver {
41
+ /** Record an enqueued `sleep_raw` batch so the next cycle corrects its nights. */
42
+ noteRaw: (batch: {
43
+ brand: string;
44
+ familyId: string;
45
+ userId: string;
46
+ deviceId: string;
47
+ rows: ReadonlyArray<Record<string, unknown>>;
48
+ }) => void;
49
+ /** (user, device) ranges still waiting for a cycle. */
50
+ pendingCount: () => number;
51
+ /** Correct every pending range and write its nights into `batchId`. Never throws. */
52
+ derive: (batchId: string, reason: FlushReason) => Promise<void>;
53
+ }
54
+ export declare function createSleepDeriver(deps: SleepDeriverDeps): SleepDeriver;
55
+ //# sourceMappingURL=sleep-derive.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sleep-derive.d.ts","sourceRoot":"","sources":["../../src/sync/sleep-derive.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAEH,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAA;AAClD,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,UAAU,CAAA;AAC5C,OAAO,KAAK,EAAE,YAAY,EAAE,WAAW,EAAE,MAAM,WAAW,CAAA;AAE1D,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,aAAa,CAAA;AAiBlD,MAAM,WAAW,gBAAgB;IAC/B,MAAM,EAAE,YAAY,CAAA;IACpB,MAAM,EAAE,YAAY,CAAA;IACpB,OAAO,EAAE,YAAY,CAAA;IACrB,8DAA8D;IAC9D,eAAe,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,CAAC,CAAA;IACpD,mDAAmD;IACnD,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,GAAG,CAAC,EAAE,MAAM,MAAM,CAAA;IAClB,MAAM,CAAC,EAAE,eAAe,CAAA;IACxB,YAAY,CAAC,EAAE,MAAM,CAAA;CACtB;AAED,MAAM,WAAW,YAAY;IAC3B,kFAAkF;IAClF,OAAO,EAAE,CAAC,KAAK,EAAE;QACf,KAAK,EAAE,MAAM,CAAA;QACb,QAAQ,EAAE,MAAM,CAAA;QAChB,MAAM,EAAE,MAAM,CAAA;QACd,QAAQ,EAAE,MAAM,CAAA;QAChB,IAAI,EAAE,aAAa,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAA;KAC7C,KAAK,IAAI,CAAA;IACV,uDAAuD;IACvD,YAAY,EAAE,MAAM,MAAM,CAAA;IAC1B,qFAAqF;IACrF,MAAM,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,WAAW,KAAK,OAAO,CAAC,IAAI,CAAC,CAAA;CAChE;AA8CD,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,gBAAgB,GAAG,YAAY,CAuHvE"}
@@ -0,0 +1,180 @@
1
+ /**
2
+ * Derived sleep — the correction step every batch cycle runs before
3
+ * `batch:complete` (sleep-correction §3).
4
+ *
5
+ * `sleep_raw` is written like any sensor table. Sessions and stages are not:
6
+ * they are derived from it by the validated correction pipeline, night by
7
+ * night, and written back into the SAME batch, so a `context: 'asleep'` rule
8
+ * woken by `batch:complete` sees corrected sleep, never raw minutes.
9
+ *
10
+ * Raw rows can arrive by three flush paths (scheduled cycle, manual flush,
11
+ * size/age triggers), so the deriver does not hang off any one flush: it
12
+ * notes every enqueued `sleep_raw` range, and the next batch cycle corrects
13
+ * all pending nights after its flushes land.
14
+ *
15
+ * Night replace (principle 66 as amended): a corrected night's END moves as
16
+ * later data arrives, and the principle-25 id moves with it, so a night is
17
+ * DELETEd locally before its new rows are written. Nights that come back with
18
+ * no primary block are left alone — replacing a night with nothing on a
19
+ * partial sync would erase sleep the next sync restores.
20
+ *
21
+ * Local only. Rows already pushed to a remote catalog are not rewritten; that
22
+ * needs a server-side answer before cloud sync is switched on.
23
+ */
24
+ import { sessionsFromCorrected } from './mapper/sleep';
25
+ import { computeNightOf } from './mapper/time';
26
+ import { correctSleepNights, nightsForEpochs } from './sleep-correction/orchestrate';
27
+ import { stagingViewsSql } from './sleep-correction/staging';
28
+ const SIX_HOURS = 6 * 3600;
29
+ function epochOf(ts) {
30
+ if (ts instanceof Date)
31
+ return Math.floor(ts.getTime() / 1000);
32
+ if (typeof ts === 'number')
33
+ return Math.floor(ts > 1e12 ? ts / 1000 : ts);
34
+ if (typeof ts === 'string') {
35
+ const ms = Date.parse(ts.includes('T') ? ts : `${ts.replace(' ', 'T')}Z`);
36
+ return Number.isFinite(ms) ? Math.floor(ms / 1000) : null;
37
+ }
38
+ return null;
39
+ }
40
+ /** The `night_of` value a session starting inside this window is stored with. */
41
+ function storedNightOf(windowStart, timeZone) {
42
+ return computeNightOf(new Date((windowStart + 60) * 1000), timeZone).toISOString().slice(0, 10);
43
+ }
44
+ /** Every 6pm→6pm night between two instants, inclusive. */
45
+ function nightsBetween(minEpoch, maxEpoch, timeZone) {
46
+ const probes = [];
47
+ for (let e = minEpoch; e < maxEpoch; e += SIX_HOURS)
48
+ probes.push(e);
49
+ probes.push(maxEpoch);
50
+ return nightsForEpochs(probes, timeZone);
51
+ }
52
+ function DELETE_STAGES_SQL(catalog) {
53
+ return `
54
+ DELETE FROM ${catalog}.main.sleep_stage
55
+ WHERE session_id IN (
56
+ SELECT session_id FROM ${catalog}.main.sleep_session
57
+ WHERE brand = $brand AND family_id = $family_id AND user_id = $user_id
58
+ AND device_id = $device_id AND night_of = CAST($night AS DATE)
59
+ )`;
60
+ }
61
+ function DELETE_SESSIONS_SQL(catalog) {
62
+ return `
63
+ DELETE FROM ${catalog}.main.sleep_session
64
+ WHERE brand = $brand AND family_id = $family_id AND user_id = $user_id
65
+ AND device_id = $device_id AND night_of = CAST($night AS DATE)`;
66
+ }
67
+ export function createSleepDeriver(deps) {
68
+ const pending = new Map();
69
+ const catalog = deps.localCatalog ?? 'memory';
70
+ const now = deps.now ?? (() => Date.now());
71
+ const lookbackDays = deps.lookbackDays ?? 30;
72
+ async function replaceNight(ctx, night) {
73
+ const params = {
74
+ brand: ctx.brand,
75
+ family_id: ctx.familyId,
76
+ user_id: ctx.userId,
77
+ device_id: ctx.deviceId,
78
+ night,
79
+ };
80
+ await deps.engine.execute(DELETE_STAGES_SQL(catalog), params);
81
+ await deps.engine.execute(DELETE_SESSIONS_SQL(catalog), params);
82
+ }
83
+ async function deriveRange(p) {
84
+ const timeZone = await deps.resolveTimezone(p.userId);
85
+ const ctx = {
86
+ brand: p.brand,
87
+ familyId: p.familyId,
88
+ userId: p.userId,
89
+ deviceId: p.deviceId,
90
+ userTimezone: timeZone,
91
+ };
92
+ for (const sql of stagingViewsSql({ ...ctx, localCatalog: catalog }))
93
+ await deps.engine.execute(sql);
94
+ const result = await correctSleepNights(deps.engine, {
95
+ nights: nightsBetween(p.minEpoch, p.maxEpoch, timeZone),
96
+ timeZone,
97
+ cutoffEpoch: Math.floor(now() / 1000) - lookbackDays * 86400,
98
+ });
99
+ const sessions = [];
100
+ const stages = [];
101
+ for (const n of result.nights) {
102
+ if (n.status === 'failed')
103
+ deps.logger?.warn('sync.sleep-derive: night failed; previous rows kept', { night: n.night, err: n.error });
104
+ if (n.status !== 'ok' || !n.rows)
105
+ continue;
106
+ const out = sessionsFromCorrected(n.rows, ctx, { settleMin: n.settleMin });
107
+ if (out.sleep_session.length === 0)
108
+ continue;
109
+ // Two conventions meet here. v3.1 names a night by its MORNING date
110
+ // (window Jun 17 18:00 → Jun 18 18:00 is "2026-06-18"); `night_of` is
111
+ // the mapper's evening date, stored as the UTC date of `computeNightOf`.
112
+ // Deleting by `n.night` would wipe the FOLLOWING night — so the key is
113
+ // derived exactly as the writer derives it, from inside the window.
114
+ const nights = new Set([
115
+ storedNightOf(n.windowStart, timeZone),
116
+ ...out.sleep_session.map(s => s.night_of.toISOString().slice(0, 10)),
117
+ ]);
118
+ for (const night of nights)
119
+ await replaceNight(ctx, night);
120
+ sessions.push(...out.sleep_session);
121
+ stages.push(...out.sleep_stage);
122
+ }
123
+ return { sessions, stages };
124
+ }
125
+ return {
126
+ noteRaw(batch) {
127
+ let min = Number.POSITIVE_INFINITY;
128
+ let max = Number.NEGATIVE_INFINITY;
129
+ for (const row of batch.rows) {
130
+ const e = epochOf(row.ts);
131
+ if (e == null)
132
+ continue;
133
+ if (e < min)
134
+ min = e;
135
+ if (e > max)
136
+ max = e;
137
+ }
138
+ if (!Number.isFinite(min))
139
+ return;
140
+ const key = `${batch.brand}|${batch.familyId}|${batch.userId}|${batch.deviceId}`;
141
+ const prev = pending.get(key);
142
+ pending.set(key, {
143
+ brand: batch.brand,
144
+ familyId: batch.familyId,
145
+ userId: batch.userId,
146
+ deviceId: batch.deviceId,
147
+ minEpoch: prev ? Math.min(prev.minEpoch, min) : min,
148
+ maxEpoch: prev ? Math.max(prev.maxEpoch, max) : max,
149
+ });
150
+ },
151
+ pendingCount: () => pending.size,
152
+ async derive(batchId, reason) {
153
+ if (pending.size === 0)
154
+ return;
155
+ const ranges = [...pending.values()];
156
+ pending.clear();
157
+ for (const p of ranges) {
158
+ try {
159
+ const { sessions, stages } = await deriveRange(p);
160
+ const tenant = { brand: p.brand, familyId: p.familyId, userId: p.userId, deviceId: p.deviceId };
161
+ if (sessions.length > 0)
162
+ await deps.buffer.push({ table: 'sleep_session', ...tenant, rows: sessions });
163
+ if (stages.length > 0)
164
+ await deps.buffer.push({ table: 'sleep_stage', ...tenant, rows: stages });
165
+ }
166
+ catch (err) {
167
+ deps.logger?.warn('sync.sleep-derive: correction failed; raw sleep kept, nights unchanged', {
168
+ userId: p.userId,
169
+ deviceId: p.deviceId,
170
+ err: err instanceof Error ? err.message : String(err),
171
+ });
172
+ }
173
+ }
174
+ // Land the derived rows inside this batch, so batch:complete follows them.
175
+ await deps.flusher.flush('sleep_session', reason, batchId);
176
+ await deps.flusher.flush('sleep_stage', reason, batchId);
177
+ },
178
+ };
179
+ }
180
+ //# sourceMappingURL=sleep-derive.js.map