@ak--47/dungeon-master 1.8.0 → 1.8.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.
@@ -28,8 +28,8 @@ In scope:
28
28
 
29
29
  Out of scope:
30
30
  - Schema changes (events, properties, funnels, superProps, userProps).
31
- Only modify schema if the hook can't possibly work without a new field —
32
- and even then, prefer changing the value enumeration over adding a new field.
31
+ Send missing fields or enumeration changes back to `/create-dungeon` for an
32
+ explicitly authorized schema edit before continuing. This skill never changes schema.
33
33
  - New top-level config knobs.
34
34
  - Removing the `hook: function...` body to start over with a new schema.
35
35
 
@@ -39,14 +39,15 @@ off, mix shift drags the blended rate) are better architected as initial
39
39
  conditions — duplicate funnels with swapped steps/props/`conversionRate`/
40
40
  `timeToConvert`/`weight` (see the "Structural trend engineering" section in
41
41
  `create-dungeon`). If a story reduces to structure, recommend the funnel
42
- change back to the schema instead of writing a hook to fight the engine
43
- the knob IS the expected value, which makes the story band knob-derivable
44
- (NAILED-capable) instead of confounded (STRONG-capped). Hooks are for
42
+ change back to the schema instead of writing a hook to fight the engine.
43
+ Derive the expected effect from the knob, then verify the report against a neutral
44
+ control; finite budgets and competing histories can change the measured effect. Hooks are for
45
45
  within-cohort behavior: segments doing more/less of something over time,
46
46
  property values that differ by cohort, injected bursts, lifecycle waves.
47
47
 
48
48
  ## Reference reading
49
49
 
50
+ - [1.8.1 verification contract](../verify-dungeon/references/alignment-contract.md) - independent reports, neutral controls, counting boundaries, and evidence sufficiency.
50
51
  - `lib/hook-helpers/index.js` — atoms (cohort, mutate, timing, inject,
51
52
  identity). One file per group; full JSDoc on each atom.
52
53
  - `lib/hook-patterns/index.js` — high-level recipes (one per Mixpanel
@@ -71,11 +72,11 @@ Hooks fire in this order per user (see `CLAUDE.md` for the canonical reference):
71
72
  2. `"scd-pre"` — SCD entries created. Mutate in place; return ignored.
72
73
  3. For each funnel: `"funnel-pre"` → `"event"` (per step) → `"funnel-post"`.
73
74
 
74
- **`funnel-pre` is now reliable for temporal patterns.** Usage funnels advance a
75
- cursor after each run, so successive `meta.firstEventTime` values spread across
76
- the user's active window. Persona and world-event modifiers apply BEFORE the
77
- hook the hook has final authority on `conversionRate`, `timeToConvert`, and
78
- `props`.
75
+ **Usage anchors do not accumulate previous funnel TTC.** Each usage run samples
76
+ from its eligible window or active day after onboarding. `meta.firstEventTime`
77
+ is the supplied anchor, not necessarily the first emitted event time. Persona
78
+ and world-event modifiers apply before `funnel-pre`; the hook can mutate
79
+ `conversionRate`, `timeToConvert`, and `props` within engine constraints.
79
80
  4. `"event"` — for non-funnel user events from `events[]`. Return value REPLACES the event.
80
81
  5. `"everything"` — array of ALL the user's events. Return array to replace.
81
82
 
@@ -186,7 +187,7 @@ if (type === 'funnel-post' && meta.experiment) {
186
187
  | cohort | `hashCohort(id, pct)` | Deterministic pct% cohort (0–100 scale). **Use this first for hidden cohorts** — replaces ad-hoc `charCodeAt % N`. When one dungeon needs several NON-overlapping cohorts, gate on disjoint `hashFloat(uid)` bands instead (e.g. `[0, 0.45)`, `[0.45, 0.70)`) |
187
188
  | shape | `applyLifecycleWave(events, uid, opts)` | Dormancy window + resurrection burst for Lifecycle reports. When-to-use: the story is "users go quiet, then come back". Gap discipline: ONE stray value moment inside the window destroys the Resurrected read — size `dormantDays` to cover ≥2 whole lifecycle periods, keep the window inside the user's lifespan |
188
189
  | shape | `applyPathBias(events, uid, opts)` | Inject a Flows path after the user's FIRST anchor occurrence. When-to-use: the story is "X% of users take this route". `share` is a 0–1 FRACTION (not `hashCohort`'s pct scale); needs ~≥0.20–0.25 to survive Sankey top-3-per-level pruning; per-step gaps clamped ≥1s so ordering survives |
189
- | shape | `applySessionShape(events, uid, opts)` | Retime the whole stream into `sessionsPerWeek` clusters of `sessionMinutes`. When-to-use: session-duration/cadence stories (sessionMetrics reads). Retiming ONLY no adds/drops; intra-session gaps stay <28min, inter-session >30min, no cluster crosses UTC midnight. Combine with `hashCohort` for per-role shapes; call BEFORE `applyPathBias` so injected paths keep their own tight gaps |
190
+ | shape | `applySessionShape(events, uid, opts)` | Retime the same records for session-duration/cadence stories. Pass known dataset bounds; explicit-bound mode rejects insufficient capacity before mutation. Legacy unbounded overfull layouts can merge sessions or cross dataset end. Call BEFORE `applyPathBias` so injected paths keep their own tight gaps. |
190
191
 
191
192
  ### Hook anti-patterns
192
193
 
@@ -236,8 +237,9 @@ writing a custom hook:
236
237
  No special hook needed; engineer cohort behavior via `engagementDecay`,
237
238
  `dropEventsWhere`, or per-user filtering in `everything`.
238
239
  - **Session metrics** ("avg session has 6 events, lasts 4 minutes") — verify
239
- with `emulateBreakdown({ type: 'sessionMetrics' })`. Trust pre-stamped
240
- `session_id`. Engineer via `avgEventsPerUserPerDay` + `engagementDecay`.
240
+ with `emulateBreakdown({ type: 'sessionMetrics' })`. Derive sessions from
241
+ timestamps; stamped `session_id` is diagnostic or explicit legacy mode.
242
+ Engineer with `applySessionShape`, passing known dataset bounds.
241
243
  - **Reentry funnels** ("power users complete the funnel 3+ times") — set
242
244
  `Funnel.reentry: true` (verifier hint). Engineer multiple completions via
243
245
  `funnel-post` injecting cloned funnel sequences for that cohort.
@@ -254,9 +256,10 @@ writing a custom hook:
254
256
  breakdown with `timeBucket: 'week'`. Engineer via temporal-windowed hooks
255
257
  using `DATASET_START.add(N, 'days')`.
256
258
  - **Identity-model dungeons** — when `identity.avgDevicePerUser > 0`
257
- (or the deprecated `hasAnonIds: true`), ALWAYS pass `profiles` to
258
- verification. Auto-builds identity map merging pre-auth `device_id`
259
- events with post-auth `user_id`.
259
+ (or the deprecated `hasAnonIds: true`), pass `profiles` for profile segments.
260
+ For identity proof, pass an explicit `identityMap` derived from emitted valid
261
+ both-ID events. Profile pools alone establish no link; the compatibility helper
262
+ can merge users without emitted stitch evidence. See the shared contract.
260
263
 
261
264
  **Schema-first reminder:** exclusion events must be declared in `events[]`
262
265
  before referencing them as `Funnel.exclusionEvents` — the validator throws
@@ -270,9 +273,9 @@ emulator can re-derive.
270
273
  | Pattern | Mixpanel analysis | Hook type | Caveat (HOOKS.md) |
271
274
  |---------|-------------------|-----------|-------------------|
272
275
  | `applyFrequencyByFrequency` | Insights — count(A) by per-user count(B) | everything | `binBy` defaults to `'distinctDays'` (v1.6) — bins match Mixpanel's per-user distinct-day counting, not raw event totals |
273
- | `applyFunnelFrequencyBreakdown` | Funnels completion by per-user count(X) | funnel-post | When funnels share a step prefix, restrict scaling to the target funnel scaling every instance lets first-occurrence funnel evaluation assemble chains across unscaled instances and the ratio never reaches the report |
276
+ | `applyFunnelFrequencyBreakdown` | Funnels - completion by per-user count(X) | funnel-post | Restrict scaling to the target funnel. Completed histories can combine competing instances; verify explicit report options and paired lift instead of assuming the per-run factor equals the report ratio. |
274
277
  | `applyAggregateByBin` | Insights — avg(prop X) by per-user count(B) | everything | Same `binBy: 'distinctDays'` default as above |
275
- | `applyTTCBySegmentV2` | Funnel TTC — broken down by user-property segment | everything | v1 (`applyTTCBySegment`, funnel-post) is **deprecated**: Mixpanel TTC reads each step's FIRST occurrence per user, so per-run gap scaling only reaches the report on `isFirstFunnel` runs. V2 finds the greedy first sequence (`findFirstSequence`) and scales that |
278
+ | `applyTTCBySegmentV2` | Funnel TTC by user-property segment | everything | v1 (`applyTTCBySegment`, funnel-post) is deprecated. V2 scales `findFirstSequence`; verify the report's completed histories separately, including restart, grace, windows, and reentry. Neither helper guarantees the requested report ratio. |
276
279
  | `applyAttributedBySource` | Conversions by Source (first/last touch) | everything | OVERWRITES the engine-stamped touch the chosen model reads; never stamps UTMs onto unstamped events (would blow the `maxTouchpointsPerUser` cap and land outside the last-10 lookback) |
277
280
 
278
281
  Use a pattern when the trend matches its analysis 1:1. Drop down to atoms when
@@ -298,13 +301,17 @@ non-negotiable design rule learned the hard way:
298
301
  visualization even though the data is there. Label-only path reads can
299
302
  INVERT when a busier cohort glues extra visible events between path steps —
300
303
  assert the share on the cohort you engineered, not globally.
304
+ The helper is append-only: `share` selects injection recipients, not exact
305
+ visible branch share. Keep competing events and measure paired branch lift.
301
306
  - **`session-shape`** (`applySessionShape`) — 30-MIN STRADDLING + MIDNIGHT
302
- RULE. Mixpanel derives sessions with a 30-min idle timeout and splits at
303
- UTC midnight. Engineered cadences must keep intra-session gaps clearly
307
+ RULE. The local default derives sessions with a 30-min idle timeout and splits
308
+ at UTC midnight, with a 24-hour maximum. Engineered cadences must keep intra-session gaps clearly
304
309
  UNDER 30min and inter-session gaps clearly OVER it — a gap that straddles
305
310
  the timeout makes session counts jitter across runs. Never let an
306
311
  engineered session cross UTC midnight (the day split cuts it in two). The
307
- atom guarantees all three; hand-rolled retiming must too.
312
+ bounded helper checks placement capacity before mutation. Legacy unbounded
313
+ overfull requests can merge sessions. Pass known bounds and measure derived
314
+ sessions; do not infer dataset end from the last observed event.
308
315
  - **`composition-drift`** — the breakdown's SHARE of a segment moves over
309
316
  time while totals stay flat (e.g. plan-mix shifts toward premium). Engineer
310
317
  by flipping an existing property value on a date-gated cohort, never by
@@ -339,9 +346,9 @@ events.push(clone); // ✅ inject from template
339
346
  return events.filter(e => !shouldDrop(e)); // ✅ filter inside `everything`
340
347
  ```
341
348
 
342
- If a trend genuinely needs a new property and the schema doesn't have it, add
343
- the property to the EVENT CONFIG with a default value (typically `[null]` or
344
- `[false]`), not via the hook.
349
+ If a trend needs an undeclared property, stop that pattern and hand the schema
350
+ request to `/create-dungeon`. Resume only after an authorized schema edit declares
351
+ the default. Do not add the field or change its enumeration in this skill.
345
352
 
346
353
  ## Identity-aware hook patterns
347
354
 
@@ -463,8 +470,9 @@ Rules:
463
470
  - `floor` must itself be derived (e.g. `target * 0.8`) — never hand-tuned to
464
471
  a run. A missed assertion means fixing the hook or the derivation, never
465
472
  relaxing the number to match output.
466
- - Set `minCohort` from the cohort math (share × numUsers × ~0.5 safety) so
467
- reduced-scale runs cap at WEAK instead of passing on noise.
473
+ - Set `minCohort` from the planned eligible population, then inspect the actual
474
+ selected-row population fields and independent users/converters. The guard
475
+ does not prove every denominator. Report insufficient evidence separately.
468
476
  - One story per pattern; story `hook` matches the doc-block numbering (`H3`).
469
477
  - The `assert` function escape hatch is discouraged — each use needs a
470
478
  comment saying why the declarative `expect` grammar can't express it.
@@ -474,12 +482,12 @@ Rules:
474
482
 
475
483
  The order of operations inside the everything hook matters when hooks interact:
476
484
 
477
- 1. **SuperProp stamping** stamp profile values onto events (always first)
485
+ 1. **Profile projection** - prefer schema-declared `stickyEventProps`; preserve projected values in clones
478
486
  2. **Temporal value mutations that DON'T need cloned events** — e.g., version stamping
479
487
  3. **Behavioral detection + event cloning** — agentic detection, KYC clones, pro clones, magic number clones
480
488
  4. **Event filtering** — churn, retention, rate-limit drops
481
- 5. **Temporal value mutations that NEED cloned events** e.g., spring price boost, gas spike, outage errors (always LAST before sort)
482
- 6. **Sort** `userEvents.sort((a, b) => new Date(a.time) - new Date(b.time))`
489
+ 5. **Temporal value mutations that NEED cloned events** - e.g., spring price boost, gas spike, outage errors (last mutation)
490
+ 6. **Return** - the engine auto-sorts after `everything` by default; no manual output sort
483
491
 
484
492
  **Why:** If a temporal mutation runs before cloning, cloned events that land in
485
493
  the temporal window miss the mutation. Moving temporal value mutations to the
@@ -489,7 +497,8 @@ end ensures ALL events in the window — original and cloned — receive the eff
489
497
 
490
498
  When a dungeon relied on deprecated config blocks (`subscription`, `attribution`,
491
499
  `features`, `geo`, `anomalies`) for properties that hooks depend on, those
492
- properties no longer appear in the data. Replace them:
500
+ properties no longer appear in the data. Request an authorized schema migration
501
+ through `/create-dungeon` first:
493
502
 
494
503
  1. Add the property to `superProps` and `userProps` with default values
495
504
  2. Assign meaningful values in the `user` hook (based on hash, persona, or profile)
@@ -506,8 +515,8 @@ control group, but not so broad they catch everyone:
506
515
  | Detection | Problem | Fix |
507
516
  |-----------|---------|-----|
508
517
  | `events.some(e => e.event === X)` with common X | 90%+ of users qualify | Require 3+ events: `events.filter(...).length >= 3` |
509
- | `charCodeAt(0) % 50 === 0` | Only 2% of users | Increase modulus denominator or use `% 10` for 10% |
510
- | `profile.tier === "premium"` | Fixed by config distribution | Adjust userProps distribution if cohort too small |
518
+ | `charCodeAt(0) % 50 === 0` | First-character distributions are biased; modulo does not imply 2% | Use `hashCohort(uid, 2)` and measure the realized population |
519
+ | `profile.tier === "premium"` | Fixed by config distribution | Request a schema distribution change if cohort is too small |
511
520
  | `earlyEvents.length >= 5` for a low-weight event | 0% qualify (impossible threshold) | Check actual distribution first, set at ~80th percentile |
512
521
 
513
522
  Target: 10-30% of users in the affected cohort for clean signal at 10K users.
@@ -546,11 +555,12 @@ Reserve drops for a single churn/retention effect per dungeon.
546
555
 
547
556
  Apply these BEFORE handing off to `/verify-dungeon`. See HOOKS.md §9 for full recipes.
548
557
 
549
- ### isStrictEvent: false is NOT optional for hook-read events
558
+ ### Opt out of strict events only when standalone occurrences are required
550
559
 
551
560
  If your hook reads `event === 'X'` and `X` is also a funnel-step event, the
552
561
  validator auto-promotes it to `isStrictEvent: true` and the engine
553
- won't emit standalone occurrences. Your cohort goes empty.
562
+ won't emit standalone occurrences. Funnel-generated occurrences remain readable;
563
+ only a cohort that depends on additional standalone traffic needs the opt-out.
554
564
 
555
565
  ```js
556
566
  // BAD — login is a funnel step + read by hook
@@ -559,16 +569,15 @@ events: [{ event: 'login', weight: 4, properties: {...} }]
559
569
  events: [{ event: 'login', weight: 4, isStrictEvent: false, properties: {...} }]
560
570
  ```
561
571
 
562
- Audit: any event referenced in the `everything` hook by name AND appearing
563
- as a funnel step needs `isStrictEvent: false`.
572
+ Audit whether the cohort needs standalone occurrences. Request any required
573
+ `isStrictEvent: false` schema edit through `/create-dungeon`; do not change it here.
564
574
 
565
575
  ### Reentry on per-instance loops
566
576
 
567
- Funnels named "X loop" / "X cycle" / "session" / repeated user behaviors
568
- need `Funnel.reentry: true`. Without it, the engine produces ONE funnel
569
- sequence per user no recurring loops. Examples that need it: workout
570
- loop, match flow, search-to-book, order fulfillment, engagement loop, tour
571
- funnel.
577
+ **`reentry` is verifier-only.** Usage volume, funnel selection, and the event
578
+ budget control generated repetitions. Set `reentry: true` for a report that
579
+ counts repeated histories; it does not generate loops. Local totals also
580
+ default to `reentry: false`, so preserve the report's explicit counting options.
572
581
 
573
582
  ### Hash-based cohorts produce textbook signals
574
583
 
@@ -577,12 +586,13 @@ deterministically:
577
586
 
578
587
  ```js
579
588
  // 2% whales with 50x trade amount → long-tail Insights distribution
580
- const isWhale = uid.charCodeAt(0) % 50 === 0;
589
+ const isWhale = hashCohort(uid, 2);
581
590
  if (isWhale && e.event === 'swap') e.trade_amount_usd *= 50;
582
591
  ```
583
592
 
584
593
  Use a large multiplier (≥10x, ideally 50x) so the signal beats soup noise.
585
- Use `% 50` for ~2% whales, `% 25` for ~4% bots, `% 10` for ~10% cohorts.
594
+ Use `hashCohort(uid, 2)`, `hashCohort(uid, 4)`, or `hashCohort(uid, 10)` for
595
+ those target percentages, then check realized eligible cohort sizes.
586
596
 
587
597
  ### Hook ordering inside `everything`
588
598
 
@@ -604,13 +614,13 @@ counts even after reduction. Either:
604
614
  ```js
605
615
  // BAD — profile.level isn't in userProps; resolves to undefined
606
616
  if (meta.profile.level >= 50) e.gold_earned *= 3;
607
- // GOOD verify by SPREAD instead, OR add level to userProps with weighted distribution
617
+ // Request a schema declaration before targeting this profile segment.
608
618
  ```
609
619
 
610
620
  When the hook references a missing profile field, you can still get the
611
621
  data spread you want (gold range), but the cohort can't be analytically
612
- recovered. Either add the userProp or rewrite the hook to use a hash
613
- cohort.
622
+ recovered. Request the missing userProp through `/create-dungeon`, or rewrite
623
+ the hook to use a hash cohort without changing schema.
614
624
 
615
625
  ## Workflow
616
626
 
@@ -648,11 +658,14 @@ cohort.
648
658
  If verify-dungeon returns WEAK, NONE, or INVERSE on any pattern at full
649
659
  fidelity, return to step 4 and refine — fix the hook or the derivation,
650
660
  never relax a threshold to match output. Iterate until all patterns score
651
- STRONG or NAILED.
661
+ STRONG or NAILED with report semantics, neutral controls, and sufficient
662
+ independent populations verified. Insufficient evidence requires more evidence,
663
+ not an automatic hook rewrite or a weaker target.
652
664
 
653
665
  ## Stopping condition
654
666
 
655
- Stop after `/verify-dungeon` reports all engineered patterns as STRONG or NAILED,
667
+ Stop after `/verify-dungeon` reports all engineered patterns as STRONG or NAILED
668
+ and the shared proof contract is satisfied,
656
669
  OR after three iterations without convergence — at that point, document what's
657
670
  still off in the dungeon's overview comment and report the gap to the user.
658
671
 
package/CHANGELOG.md CHANGED
@@ -2,6 +2,47 @@
2
2
 
3
3
  All notable changes to `@ak--47/dungeon-master`.
4
4
 
5
+ ## 1.8.1 — 2026-09-12
6
+
7
+ ### Fixed
8
+
9
+ - Align funnel counting with source-derived Mixpanel contracts: shared first/last
10
+ steps finalize correctly with or without reentry, completion grace consumes
11
+ events through the inclusive two-second boundary, and first/last-touch
12
+ breakdowns merge properties across reached steps.
13
+ - Derive session boundaries from the full event stream before hold-property-constant
14
+ partitioning, so unrelated activity can keep a session open.
15
+ - Keep onboarding after user creation and usage after onboarding. Preserve retry
16
+ entries when strict event-count capacity allows. Short windows retain partial
17
+ output and report capacity limits through existing warnings.
18
+ - Reconcile engine-generated identity against surviving auth events, including
19
+ engine-created duplicates and amplification clones. Preserve explicit hook
20
+ overrides and the synthetic experiment identity exception.
21
+ - Select the earliest chronological anchor for append-only path injection.
22
+
23
+ ### Added
24
+
25
+ - Optional `datasetStart` and `datasetEnd` bounds for `applySessionShape`, preventing
26
+ retimed events from being clipped outside a known dataset window. Explicit bounds
27
+ reject impossible session layouts before mutation; calls without bounds retain
28
+ legacy full-day placement.
29
+ - An offline `tests/alignment` family with mixed-dungeon trend checks, neutral
30
+ controls, multiple seeds, sample-size guards, counting contracts, and a diagnostic
31
+ sweep with a hard ten-minute deadline. The runner denies network access at the OS
32
+ level on macOS and fails closed elsewhere.
33
+ - A control inventory, failure-first repair evidence, and measured operating ranges.
34
+ Release validation passed 175 alignment tests and 2,022 unit/integration tests, with
35
+ one existing skip. The 297-cell sweep generated 17.08 million events cumulatively:
36
+ 125 cells met their evidence thresholds and 172 had insufficient evidence.
37
+
38
+ ### Compatibility
39
+
40
+ Existing call forms and defaults remain supported. Session bounds are additive,
41
+ not required. Corrected timestamps, identities, and counts can change generated
42
+ output relative to 1.8.0. Totals counting still requires explicit `reentry: true`
43
+ for repeated histories. These tests use local Mixpanel source-derived contracts,
44
+ not live engine execution, and do not establish complete coverage of every knob.
45
+
5
46
  ## 1.8.0 — 2026-09-10
6
47
 
7
48
  ### Added — `standaloneEvents`: identity-less metric snapshots
package/HOOKS.md CHANGED
@@ -537,12 +537,22 @@ enter on birth, or drop next-day spill in an `everything` hook.
537
537
 
538
538
  ### 2.8 Funnel reentry: state machine resets after completion
539
539
 
540
- Reference: `history.cpp` (`last_step_starts_next_funnel`). With reentry
541
- enabled, after the state machine reaches the final step the engine resets to
542
- step 0 and continues scanning. `result.completions` reports the total. In
543
- `countMode: 'totals'` the engine returns one `FunnelResult` per completion
544
- (simultaneous histories one user, many funnel completions). Without
545
- reentry the funnel runs once per user.
540
+ Reference: `history.cpp` (`history_is_mutable`) and `funnel_query.cpp`
541
+ (shared first/last step handling). With `reentry: true`, completion absorbs
542
+ events through the inclusive 2-second grace period. The next event beyond
543
+ grace starts the next scan. Conversion-window expiry can restart earlier.
544
+ `graceperiod: false` disables the completion wait.
545
+
546
+ When an event records the ordered last step and also matches the ordered
547
+ first step, it closes one attempt and anchors the next immediately. Both
548
+ selectors must match. Any-order edges do not use this exception.
549
+ `woRepeat` still restarts only at window expiry.
550
+
551
+ `result.completions` reports repeat completions in uniques mode. In
552
+ `countMode: 'totals'`, the engine returns one `FunnelResult` per attempt,
553
+ including partial attempts. **Compatibility unchanged:** `reentry` defaults
554
+ to `false`, even for totals. Totals alone does not enable analytics general
555
+ counting's repeat-history behavior.
546
556
 
547
557
  ### 2.9 HPC (Hold Property Constant) — parallel sub-funnels
548
558
 
@@ -555,13 +565,24 @@ directly, or (v1.6) pass `holdPropertyConstant: '<prop>'` to the
555
565
  `funnelFrequency` emulator — it routes through the HPC engine and reports
556
566
  per-held-value sub-funnel counts.
557
567
 
568
+ Session windows derive ordinals from the full user stream before HPC
569
+ partitioning. Events with another held value can bridge a session but cannot
570
+ fill steps in the current bucket. This applies to explicit session windows
571
+ and `countMode: 'sessions'`. The local session defaults remain a 30-minute
572
+ timeout, 24-hour maximum, and UTC day boundaries.
573
+
558
574
  ### 2.10 Funnel segment modes (FIRST_TOUCH / LAST_TOUCH / STEP)
559
575
 
560
576
  Reference: `options.hpp` `funnel_segment_mode`; `history.cpp`
561
577
  `property_set_buffer`. The engine snapshots the matched event's properties
562
- at every funnel step. Segmentation chooses which step's properties to use:
563
- FIRST_TOUCH (step 0), LAST_TOUCH (last reached), or STEP N (specific index).
564
- Enable with `evaluateFunnel({ trackStepProperties: true })`, then pick with
578
+ at every reached position. FIRST_TOUCH and LAST_TOUCH merge those snapshots
579
+ in recorded path order, including partial and any-order paths. The first
580
+ or last defined non-null value wins, respectively. Undefined never replaces
581
+ a defined value; null never replaces a defined non-null value. If only null
582
+ and undefined are present, null wins. Snapshots remain unchanged.
583
+
584
+ STEP N selects one reached position without merging fallback values.
585
+ Enable with `evaluateFunnel(events, steps, { trackStepProperties: true })`, then pick with
565
586
  `resolveFunnelSegment(result, 'first' | 'last' | { step: N })`.
566
587
 
567
588
  ### 2.11 Engine-validation guarantees (v1.5+)
@@ -1843,8 +1864,9 @@ event set rather than one value moment.
1843
1864
  **Hook:** `everything`
1844
1865
  **Mixpanel report:** Flows — top paths after the anchor event show the engineered branch (Section 2.17)
1845
1866
 
1846
- **In Mixpanel:** ~30% of users who view an item proceed straight down
1847
- `add to cart begin checkout`, making it the dominant Sankey branch.
1867
+ **In Mixpanel:** Bias the first branch toward `add to cart begin checkout`.
1868
+ The helper selects ~30% of users for append-only injection. Existing traffic
1869
+ can interrupt the branch; the final branch share is not guaranteed to be 30%.
1848
1870
 
1849
1871
  ```js
1850
1872
  import { applyPathBias } from "@ak--47/dungeon-master/hook-helpers";
@@ -1889,6 +1911,8 @@ if (type === "everything") {
1889
1911
  sessionsPerWeek: 3,
1890
1912
  eventsPerSession: 5,
1891
1913
  sessionMinutes: 25,
1914
+ datasetStart: meta.datasetStart,
1915
+ datasetEnd: meta.datasetEnd,
1892
1916
  });
1893
1917
  }
1894
1918
  ```
@@ -1897,12 +1921,35 @@ if (type === "everything") {
1897
1921
  event set (after the `everything` hook), so wholesale timestamp rewrites no
1898
1922
  longer leave stale session labels. The atom keeps intra-session gaps well
1899
1923
  under the 30-min timeout (spacing capped at 20min + bounded jitter), keeps
1900
- inter-session gaps well over it, and never crosses UTC midnight inside one
1924
+ inter-session gaps over it when explicit bounds are supplied, and never crosses UTC midnight inside one
1901
1925
  engineered session (the day-boundary split would cut it). Retiming only — no
1902
1926
  events are added or dropped, so total counts and event mixes are untouched.
1903
1927
  Session count follows `min(sessionsPerWeek × weeks, ceil(N /
1904
1928
  eventsPerSession))`: scarce users get fewer sessions, not fabricated events.
1905
1929
 
1930
+ `datasetStart` and `datasetEnd` are additive, optional arguments. Existing
1931
+ calls with neither bound keep the original full-UTC-day placement between
1932
+ the user's first and last active days. A two-event stream at 12:00/12:20 can
1933
+ still request two sessions with `eventsPerSession: 1` and `sessionMinutes: 5`.
1934
+ Legacy overfull requests also keep their old behavior: they do not throw,
1935
+ but their clusters can merge under the 30-minute timeout.
1936
+
1937
+ The bounds accept ISO strings, unix seconds, or unix milliseconds. Hook
1938
+ metadata uses unix seconds and can be passed directly, as in the example.
1939
+ Either bound enables constrained placement; an omitted side uses the start
1940
+ of the first active UTC day or the end of the last active UTC day. The helper
1941
+ cannot infer `datasetEnd` from the last event. Pass known bounds when the
1942
+ dataset ends partway through an active day, including an inclusive midnight
1943
+ endpoint, to prevent later engine clipping.
1944
+
1945
+ With explicit bounds, partial days compress clusters, including zero-duration
1946
+ clusters at midnight. If the requested sessions cannot fit inside a week's
1947
+ available day slices, the helper throws `RangeError` before changing any
1948
+ events. Invalid bounds also throw. It never silently reduces the target or
1949
+ drops records. Widen the allowed window or reduce the session target. Exact
1950
+ session separation assumes UTC and the default 30-minute timeout, without
1951
+ a maximum session duration.
1952
+
1906
1953
  ---
1907
1954
 
1908
1955
  ## 5. Phase 3 Atom Reference
@@ -1933,7 +1980,7 @@ Import from `@ak--47/dungeon-master/hook-helpers`:
1933
1980
  | `splitByAuth` | identity | `(events, authTime) -> { preAuth, postAuth, stitch }` | Partition by auth boundary |
1934
1981
  | **`applyLifecycleWave`** | shape | `(events, uid, { dormantFromDay, dormantDays, resurrectBurst?, valueMomentEvent, dropAll? }) -> events[]` | Clean dormancy gap + resurrection burst; sweeps the ENTIRE window by timestamp (v1.6, recipe 4.29). Returns a NEW array |
1935
1982
  | **`applyPathBias`** | shape | `(events, uid, { anchor, path, share, gapSeconds? }) -> events[]` | Inject a Flows path after the user's first anchor for ~`share` (fraction) of users; skips users missing any step template (v1.6, recipe 4.30) |
1936
- | **`applySessionShape`** | shape | `(events, uid, { sessionsPerWeek, eventsPerSession, sessionMinutes }) -> events[]` | Retime the stream into deterministic session clusters intra-gaps 30min, inter-gaps 30min, never crosses UTC midnight (v1.6, recipe 4.31) |
1983
+ | **`applySessionShape`** | shape | `(events, uid, { sessionsPerWeek, eventsPerSession, sessionMinutes, datasetStart?, datasetEnd? }) -> events[]` | Preserve records; default legacy full-UTC-day placement. Optional bounds constrain placement and throw atomically on insufficient capacity (recipe 4.31) |
1937
1984
 
1938
1985
  **Inject atoms + v1.5:** the engine auto-sorts events by time after the
1939
1986
  `everything` hook (`autoSortAfterEverything: true` default — see Principle
package/README.md CHANGED
@@ -957,7 +957,7 @@ styles: `support`, `review`, `search`, `feedback`, `chat`, `email`, `forum`, `co
957
957
  ## scripts
958
958
 
959
959
  ```bash
960
- npm test # full vitest test suite
960
+ npm test # default unit/integration/e2e suite; prunes data/tmp
961
961
  npm run typecheck # typescript check
962
962
  npm run dungeon:run # run a dungeon file locally
963
963
  npm run dungeon:to-json # convert JS dungeon to JSON (for UI import)
@@ -978,6 +978,8 @@ node scripts/verify-runner.mjs <path> [prefix] # generate at full fidelity for
978
978
 
979
979
  ## tests
980
980
 
981
+ 1.8.1 compatibility and output changes: [upgrade guide](docs/guides/1.8.1-upgrade-guide.md).
982
+
981
983
  vitest tests live under `tests/` in three tiers:
982
984
 
983
985
  | dir | scope | wall time |
@@ -999,6 +1001,40 @@ npx vitest tests/unit # watch mode
999
1001
 
1000
1002
  `tests/e2e/engine-shape-full-sweep.test.js` skips itself unless `RUN_FULL_SWEEP=1` is set (it wraps the long-running 194-combo engine sweep).
1001
1003
 
1004
+ ### editor and offline alignment tests
1005
+
1006
+ VS Code discovers unit, integration, E2E, and alignment tests through one serial
1007
+ `vitest.editor.config.js`. The workspace disables Go test
1008
+ discovery and ignores the overlapping diagnostic Vitest configs. After changing
1009
+ these settings, run **Developer: Reload Window** if stale providers or test runs
1010
+ remain in the Testing panel. Editor runs omit the pruning setup, but they are not
1011
+ OS-sandboxed.
1012
+
1013
+ Use **Tasks: Run Test Task** for `test: regression (offline, macOS)`, or choose
1014
+ the named alignment and sweep tasks from **Tasks: Run Task**. These test tasks
1015
+ never invoke the prune or dungeon-run tasks. Existing dungeon-run cleanup is
1016
+ unchanged and remains separate from testing.
1017
+
1018
+ The `test: engine matrix`, `test: engine short sweep`, and `test: engine full sweep`
1019
+ tasks expose the direct-run engine checks. They are opt-in and use OS network denial.
1020
+ The full engine wrapper is visible under E2E but skipped until `RUN_FULL_SWEEP=1`.
1021
+ Direct-run `.mjs` scripts are not Vitest tests, so they do not get a separate
1022
+ `engine` folder in the Testing tree. The legacy engine sweeps do not use the
1023
+ alignment runner's ten-minute deadline. E2E tests may write files or perform
1024
+ network operations when run directly in the editor; editor execution is not an
1025
+ offline guarantee.
1026
+
1027
+ ```sh
1028
+ node tests/alignment/run.mjs # offline alignment gate
1029
+ node tests/alignment/run.mjs --sweep --timeout-ms=600000 # opt-in bounded sweep
1030
+ ```
1031
+
1032
+ Alignment is a separate test family, excluded from `npm test` but visible in the editor.
1033
+ Its runner enforces OS network denial on macOS, fails closed elsewhere, and kills
1034
+ workers at the ten-minute deadline. Tests and reports live in the source checkout;
1035
+ they are not included in the npm package. The default `npm test` and direct root
1036
+ Vitest commands still prune `data` and `tmp` through their global setup.
1037
+
1002
1038
  ### engine tests (direct-run, NOT vitest)
1003
1039
 
1004
1040
  `tests/engine/` houses direct-run regression tests at scale. these are NOT vitest-compatible — invoke with `node` directly. used to catch engine regressions across a wide variety of dungeon configurations and for ad-hoc chart inspection. outputs land in `./tmp/` (gitignored).